117 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
888078876a feat: v0.3.0 — State, Memory, message_status, MCP instructions
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
Release / Publish multi-arch images (push) Has been cancelled
Phase B + C + message delivery status.

State: shared key-value store per mesh. set_state pushes changes
to all peers. get_state/list_state for reads. Peers coordinate
through shared facts instead of messages.

Memory: persistent knowledge with full-text search (tsvector).
remember/recall/forget. New peers recall context from past sessions.

message_status: check delivery status with per-recipient detail
(delivered/held/disconnected).

Multicast fix: broadcast and @group messages now push directly to
all connected peers instead of racing through queue drain.

MCP instructions: dynamic identity injection (name, groups, role),
comprehensive tool reference, group coordination guide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:29:45 +01:00
Alejandro Gutiérrez
02b1e5695f feat: v0.2.0 — Groups (@group routing, roles, wizard)
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
Release / Publish multi-arch images (push) Has been cancelled
Phase A of the claudemesh spec. Peers can now join named groups
with roles, and messages route to @group targets.

Broker:
- @group routing in fan-out (matches peer group membership)
- @all alias for broadcast
- join_group/leave_group WS messages + DB persistence
- list_peers returns group metadata
- drainForMember matches @group targetSpecs in SQL

CLI:
- join_group/leave_group MCP tools
- send_message supports @group targets
- list_peers shows group membership
- PeerInfo includes groups array
- Peer name cache for push notifications

Launch:
- --role flag (optional peer role)
- --groups flag (comma-separated, e.g. "frontend:lead,reviewers")
- Interactive wizard for role + groups when flags omitted
- Groups written to session config for broker hello

Spec: SPEC.md added with full v0.2 vision (groups, state, memory)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:06:16 +01:00
Alejandro Gutiérrez
663f800b4b fix: v0.1.16 — fix message delivery between same-member sessions
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
Release / Publish multi-arch images (push) Has been cancelled
excludeSenderMemberId blocked delivery to ALL peers sharing the
same member_id (all sessions from one join). Replaced with
excludeSenderSessionPubkey which only excludes the sender's own
session — peers with different session pubkeys receive correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:44:29 +01:00
Alejandro Gutiérrez
2557235c68 fix: v0.1.15 — production hardening (7 fixes)
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
Release / Publish multi-arch images (push) Has been cancelled
Broker:
- Sweep stale presences (3 missed pings = disconnect, 30s interval)
- Exclude sender from broadcast fan-out + queue drain

CLI:
- Decrypt fallback: try base64 plaintext if crypto_box fails
- Stable session keypair across WS reconnects
- Peer name cache (30s TTL) instead of list_peers per push
- Clean up orphaned tmpdirs from crashed sessions (>1 hour old)
- Read displayName from config file (not just env var)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:22:04 +01:00
Alejandro Gutiérrez
a987e9e27b fix(cli): v0.1.14 — persist displayName in config file, not env var
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
Release / Publish multi-arch images (push) Has been cancelled
Write displayName into tmpdir config.json so the MCP server reads
it directly. Env vars from claudemesh launch may not propagate to
MCP child processes spawned by Claude Code. Config file is reliable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:18:08 +01:00
Alejandro Gutiérrez
ff86db615f style(cli): tighten autonomous mode confirmation copy
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:54:55 +01:00
Alejandro Gutiérrez
4aa61b40e2 feat(cli): v0.1.13 — autonomous mode with user confirmation
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
Release / Publish multi-arch images (push) Has been cancelled
claudemesh launch now passes --dangerously-skip-permissions to
claude so peers can chat without per-tool-call approval prompts.
Shows a clear explanation before launch; user confirms with Enter.
Skip with -y/--yes for CI or repeat launches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:53:13 +01:00
Alejandro Gutiérrez
4afe365c00 fix(cli): v0.1.12 — resolve sender display name in push notifications
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
Release / Publish multi-arch images (push) Has been cancelled
onPush now queries list_peers to resolve the sender's pubkey to their
display name. Instructions updated to tell Claude to reply by name
instead of raw pubkey. Fixes two-way messaging between named peers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:45:40 +01:00
Alejandro Gutiérrez
92bb276a3e fix: v0.1.11 — fix crypto_box decryption with session pubkeys
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
Release / Publish multi-arch images (push) Has been cancelled
Store sender's sessionPubkey on message_queue at send time.
drainForMember returns COALESCE(sender_session_pubkey, peer_pubkey)
so the recipient gets the correct sender key for decryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:23:42 +01:00
Alejandro Gutiérrez
af8f8ed1f9 feat: v0.1.10 — per-session ephemeral keypairs
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
Release / Publish multi-arch images (push) Has been cancelled
Each WS connection generates its own ed25519 keypair (sessionPubkey)
sent in the hello handshake. The broker stores it on the presence
row and uses it for message routing + list_peers. This gives every
`claudemesh launch` a unique crypto identity without burning invite
uses — member auth stays permanent, session identity is ephemeral.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:14:33 +01:00
Alejandro Gutiérrez
c8682dd700 fix(cli): deduplicate --dangerously-load-development-channels flag
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:56:30 +01:00
Alejandro Gutiérrez
004602a83c fix(cli): v0.1.8 — remove Zod dependency (bun bundler crash)
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
Release / Publish multi-arch images (push) Has been cancelled
Replace Zod schemas with plain TypeScript validation in env.ts,
config.ts, and invite/parse.ts. Zod 4 classes break under bun
build --target=node (Class2 is not a constructor).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:51:42 +01:00
Alejandro Gutiérrez
2a2aac3622 feat(cli): v0.1.7 — --name, --mesh, --join flags for launch
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
Release / Publish multi-arch images (push) Has been cancelled
- `claudemesh launch --name Mou` sets per-session display name
- `claudemesh launch --mesh car-dealers` selects mesh (interactive picker if >1)
- `claudemesh launch --join <token-or-url>` joins a mesh inline before launching
- Broker stores per-presence displayName override (prefers over member default)
- Session config isolated via tmpdir (auto-cleanup on exit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:45:29 +01:00
Alejandro Gutiérrez
e0659b0b6f feat(cli): v0.1.6 — name-based peer routing in send_message
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
Release / Publish multi-arch images (push) Has been cancelled
resolveClient() now resolves display names via list_peers WS query.
Supports exact match, partial match (unique substring), and falls
back to pubkey/channel/broadcast pass-through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:09:00 +01:00
Alejandro Gutiérrez
4c057be069 fix(web): re-apply all landing page content fixes (linter reverted)
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
A linter/formatter reverted our content edits. Re-applying:
- Hero: concrete claims, no WhatsApp/Slack promises, beta pricing
- Logo bar: tech stack instead of fake customer logos
- Pricing: single honest Public Beta tier (removed $12/$24/$99)
- FAQ: real install flow, honest pricing language
- Features: claudemesh.com/install URL
- Toaster: v0.1.4 announcement
- Copy: "volunteers" / "shares" instead of jargon
- Links: #docs → GitHub README, claudemesh.sh → claudemesh.com

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:02:44 +01:00
Alejandro Gutiérrez
aaab7feea6 fix(web): restore turbopack SVG loader (fixes React #130)
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
The turbopack.rules config for @svgr/webpack was removed during
the Payload integration attempts. Without it, SVG imports return
raw module objects instead of React components. This crashes
LocaleCustomizer → Icons.UnitedKingdom → object → React #130.

Next.js 16.2.2 supports turbopack in production builds, so this
config is safe now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:48:36 +01:00
Alejandro Gutiérrez
af13125424 chore(web): restore next.js 16.2.2 (React #130 is pre-existing)
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
The hydration crash exists on both 16.0.10 and 16.2.2 — it's a
pre-existing component bug, not a Next.js regression. Stay on
latest for security + Payload compat when we re-add it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:38:22 +01:00
Alejandro Gutiérrez
4c52ee236c feat(cli): v0.1.5 — live peer discovery + summaries (Step 16)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Wire list_peers and set_summary MCP tools to the broker's WS
protocol instead of returning stubs. Peers can now discover each
other, see status/summary, and route messages by display name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:37:40 +01:00
Alejandro Gutiérrez
7d51f101d7 fix(web): downgrade next.js 16.2.2 → 16.0.10 (hydration crash)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Next.js 16.2.2 causes React #130 on client hydration in
production standalone output. Server renders fine but client
JS crashes. Downgrade to 16.0.10 which was the last working
version. Payload CMS is fully removed from prod so the
turbopack restriction is no longer relevant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:31:15 +01:00
Alejandro Gutiérrez
d8bafe3144 fix(web): fully remove payload runtime from production build
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
Remove ALL Payload imports, withPayload wrapper, and (payload)
routes. Blog index + changelog are now static data arrays.
Blog post at /blog/peer-messaging-claude-code is static TSX.

Payload CMS stays as a dev dependency for future local admin
but has zero presence in the production build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:25:02 +01:00
Alejandro Gutiérrez
2be08ab85f fix(web): withPayload + redirect admin + externalized packages
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
Final working pattern: withPayload via require() for build
compatibility, admin page replaced with redirect (no RootPage
import = no React #130), payload packages externalized from
turbopack bundle. Blog/changelog use server-side getPayload().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:16:38 +01:00
Alejandro Gutiérrez
d3e60d4d82 fix(web): externalize payload + esbuild from turbopack bundle
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
Turbopack tries to parse esbuild's native binary as JS, causing
build failure. Externalize all Payload-related packages so they
resolve at runtime, not bundled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:35:03 +01:00
Alejandro Gutiérrez
9cefe863e3 fix(web): fully remove withPayload + admin routes from prod
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
withPayload crashes ALL routes with React #130 in standalone
output — even with admin page replaced by redirect. The wrapper
injects a client-side ConfigProvider that fails hydration.

Removed: withPayload wrapper, entire (payload) route group.
Kept: payload.config.ts, migrations, blog/changelog server-side
queries with graceful DB fallback.

Payload admin runs on local dev only (add withPayload back in
next.config when running pnpm dev). Production content via
static TSX pages or future API-based publishing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:30:26 +01:00
Alejandro Gutiérrez
78c80cc43c fix(web): withPayload for build, admin redirects to home
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
Keep withPayload (needed for build compilation) but replace the
admin RootPage with a redirect. The RootPage's ConfigProvider
causes React #130 in standalone output. Blog/changelog use
server-side getPayload() which works fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:26:13 +01:00
Alejandro Gutiérrez
59ce33f943 fix(web): disable withPayload (React #130 on all routes)
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
The withPayload wrapper injects a client-side ConfigProvider that
crashes hydration on every route when the Payload admin can't
initialize in standalone output. Blog/changelog pages use server-
side getPayload() which works without the wrapper.

Payload admin at /payload is disabled until standalone server
init is implemented. All user-facing content works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:23:36 +01:00
Alejandro Gutiérrez
2cdcdccbc9 fix(web): exclude /payload from i18n middleware + restore routes
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:11:49 +01:00
Alejandro Gutiérrez
9653171b78 feat(web): payload prod db migration + migration files
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:08:23 +01:00
Alejandro Gutiérrez
d14bdf6b5a fix(web): regenerate payload importMap for /payload route
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:01:16 +01:00
Alejandro Gutiérrez
f1af8c0a79 fix(web): payload at /payload route (cuidecar pattern)
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
Replicate working cuidecar Payload setup:
- require() instead of ESM import for withPayload
- routes.admin = "/payload" to avoid /admin conflicts
- (payload)/payload/ route group with own layout + importMap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:51:06 +01:00
Alejandro Gutiérrez
96cae38196 fix(web): remove payload admin routes + withPayload (stabilize prod)
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
Payload CMS integration crashes the entire production app — the
withPayload wrapper + admin routes break when DB tables don't
exist and the layout conflicts with i18n routing.

Keeping: payload.config.ts, blog/changelog pages with graceful
DB fallback, static blog post page. Payload admin will be added
back once properly integrated with a dedicated route group that
doesn't inherit the main app layout.

The blog post at /blog/peer-messaging-claude-code is static TSX
and works without Payload runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:48:25 +01:00
Alejandro Gutiérrez
a14b6c28dd fix(web): restore withPayload wrapper for production
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:43:18 +01:00
Alejandro Gutiérrez
479d6a454a fix(web): remove withPayload wrapper (crashes entire prod app)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:38:47 +01:00
Alejandro Gutiérrez
c5bf1c303f feat(web): publish blog post as static page
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
Static TSX page at /blog/peer-messaging-claude-code while Payload
admin is not yet configured in production. Full 1100-word post on
protocol, dev-channels, prompt-injection, and next steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:29:17 +01:00
Alejandro Gutiérrez
c0cb19c53a feat(web): payload uses postgres in prod, sqlite locally
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
Production containers get DATABASE_URL (postgres) — Payload
creates tables in a 'payload' schema. Local dev falls back to
SQLite file for zero-config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:23:50 +01:00
Alejandro Gutiérrez
b758fe07ff fix(web): graceful fallback when payload db unavailable
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
Production has no SQLite — Payload pages now catch connection
errors and render empty state instead of crashing with React #130.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:21:04 +01:00
Alejandro Gutiérrez
8de952d91b fix(web): force-dynamic on payload pages (no DB at build time)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:15:53 +01:00
Alejandro Gutiérrez
03ca9f10d3 fix(web): sqlite url needs file: prefix for libsql
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:13:27 +01:00
Alejandro Gutiérrez
8bd8d1ff76 fix(web): remove payload REST API route + cli backup guards
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
Remove Payload's /api/[...slug] route that conflicts with existing
/api/[...route]. Blog/changelog pages use Payload's local API.

Includes cli install.ts backup + assertNoMcpLoss guards (from
worktree agent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:11:09 +01:00
Alejandro Gutiérrez
57a6af5013 fix(web): align @next/bundle-analyzer to 16.2.2
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:05:25 +01:00
Alejandro Gutiérrez
067ef10b70 fix(web): upgrade next.js 16.0.10 → 16.2.2 (payload compat)
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
Payload CMS v3.81 withPayload() requires Next.js >=16.1.0 for
production turbopack builds. Upgrade resolves the build failure.

Reverts the dev-only withPayload workaround — now loads normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:57:05 +01:00
Alejandro Gutiérrez
6b062ab239 fix(web): skip payload withPayload in production build
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
Payload CMS v3.81 withPayload() injects a turbopack config key
that Next.js 16.0.10 rejects in production builds (needs >=16.1).
Load withPayload only in dev; production gets a pass-through.

Payload admin works locally; production serves blog/changelog
as regular Next.js pages querying the Payload API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:56:08 +01:00
Alejandro Gutiérrez
5c4cb2cf84 fix(web): remove turbopack config entirely (prod build)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:48:45 +01:00
Alejandro Gutiérrez
8fa2bb5cd2 docs: refine blog post + add Anthropic team contacts to outreach
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:27 +01:00
Alejandro Gutiérrez
253e0ac43c fix(web): turbopack config dev-only (prod build compat)
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
Next.js 16.0.10 fails production builds with turbopack config
present (needs >=16.1.0). Gate it behind NODE_ENV !== production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:41:38 +01:00
Alejandro Gutiérrez
8fca7fb21a chore: personalize outreach + blog hero image
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:30:54 +01:00
Alejandro Gutiérrez
8c7a6a05c3 docs: blog post draft + outreach templates (Anthropic pitch)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:24:34 +01:00
Alejandro Gutiérrez
8e906daf6f feat(web): /about page — builder story + background
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:23:49 +01:00
Alejandro Gutiérrez
de684c44bb feat(web): payload cms v3 + blog + changelog data model
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:22:40 +01:00
Alejandro Gutiérrez
66b9696b2d test(cli): add crypto roundtrip and invite parse tests
Cover encryptDirect/decryptDirect with three scenarios (happy path,
wrong recipient, tampered ciphertext) and invite link parsing with
round-trip, expiry rejection, and malformed input handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:18:27 +01:00
Alejandro Gutiérrez
09c5d759fa fix(cli): rename duplicate setStatus to setConnStatus in BrokerClient
The private setStatus(ConnStatus) conflicted with the public
setStatus("idle"|"working"|"dnd") method, causing TS2393 under strict
typecheck. Rename the private one to setConnStatus and update all
internal call sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:18:22 +01:00
Alejandro Gutiérrez
a1c6c6dc6a fix(web): hero honesty + logo bar + FAQ accuracy
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
Three surgical edits for credibility:

Hero subheadline: remove WhatsApp/Slack/phone promises (roadmap,
not shipped), replace "reachable from anywhere you are" (vague)
with concrete value prop: E2E encrypted, delivered mid-turn as
<channel> reminders, broker never sees plaintext. Change "Free
and open-source. Forever." → "Open-source CLI. Free during
public beta." to match the pricing section.

Logo bar: remove Vercel/Linear/Stripe/Supabase/Shopify/Figma
(not actual customers). Replace with tech stack labels: Claude
Code, MCP, libsodium, Bun, TypeScript, MIT.

FAQ: fix "Is claudemesh free?" to match beta pricing. Fix "How
do I get started?" to reference the real curl installer instead
of nonexistent npx claudemesh init. Fix "Which Claude Code
versions?" to name actual install + launch flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:13:16 +01:00
Alejandro Gutiérrez
00b5ba8190 feat(web): /install shell script + real curl one-liner on landing
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
Landing page showed \`curl -fsSL claudemesh.sh/install | bash\` but
the domain didn't resolve, so anyone copy-pasting got a DNS error.

Ship:
- apps/web/src/app/install/route.ts: GET returns an auditable bash
  installer (Node preflight, npm install -g claudemesh-cli, runs
  claudemesh install, prints next steps, colored output). No Node
  auto-install — fails clean if missing with a pointer.
- apps/web/src/proxy.ts: exclude /install from the i18n matcher so
  Next.js returns the shell script unmangled.
- hero.tsx + features.tsx: swap claudemesh.sh → claudemesh.com.

Test: curl http://localhost:3000/install | bash -n → OK.
Content-Type: text/x-shellscript; charset=utf-8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:37:39 +01:00
Alejandro Gutiérrez
ccff802163 fix(web): rewrite pricing to match shipped product (honest beta tier)
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
The 6-tier grid was selling features that don't exist yet:
- Pro \$12/mo: dashboard, peer registry, message history (not built)
- Plus \$24/mo: Tailscale mesh (already default), MCP bridge (free),
  audit log (not built)
- Team \$99/mo: \"self-hosted broker\" AND \"25 peers\" AND
  \"unlimited peers\" — three contradictions in one tier
- Business \$499/mo: multi-region, retention, Slack/Linear (roadmap)
- Enterprise: claimed \"SOC 2 pack\" without certification

Replaced with a single Public-Beta card:
- Free, no card required
- Two columns: Shipping today (verified against source) + Roadmap
  v0.2–v0.3 (clearly labeled)
- Promise: \"Beta users keep the free plan for life\"

Non-additive rewrite of a shipped section. Authorized by user
explicitly; required because the prior pricing created refund +
legal risk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:32:48 +01:00
Alejandro Gutiérrez
231618c595 fix(web): replace 9 placeholder # links + 2 jargon phrases
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Surgical fixes on shipped marketing sections. All changes are link
targets (9) or two-word replacements (2) — no structural edits.

Links:
- "Read the docs" / "documentation" (hero, cta, features) → point
  to the public CLI repo README (canonical docs until /docs exists)
- "Pair your machines" (laptop-to-laptop) → /auth/register
- "Open the dashboard" (surfaces, meets-you) → /dashboard
- "Install" (meets-you) → CLI repo README install section
- "VS Code" / "JetBrains" (meets-you) → CLI repo README (MCP setup)

Copy:
- "self-nominates" → "volunteers"
- "surfaces the history" → "shares the history"

Additive polish per the v0.1.0 web prototyping rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:27:36 +01:00
Alejandro Gutiérrez
f698aaeac7 feat(cli): stateful welcome screen + v0.1.4 bump
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
Running \`claudemesh\` with no args now detects install state and
prints context-appropriate guidance: suggests \`install\` if MCP
not registered, \`join\` if no meshes, \`launch\` if ready.
Replaces the static HELP dump with a first-run wizard that meets
users where they are.

Static HELP still available via --help.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:19:27 +01:00
Alejandro Gutiérrez
8810aa1e9e feat(cli): --version, status, doctor commands (v0.1.3)
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
Three Tier-2 polish commands for debugging + discoverability:

- claudemesh --version / -v: print CLI version (baked from
  package.json at build time via Bun JSON import).
- claudemesh status: WS-probe each joined mesh's broker, report
  reachability per mesh. Exit 1 if any broker unreachable.
- claudemesh doctor: run 6 preconditions — Node>=20, claude on PATH,
  MCP registered, hooks registered, config file parses + chmod 0600,
  mesh keypairs validate. Each check has a pass/fail + fix hint.
  Exit 0 if all pass.

Help text now leads with version (\"claudemesh v0.1.3 —\").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:01:52 +01:00
Alejandro Gutiérrez
fa234fae25 feat(web): announce claudemesh-cli v0.1.2 in news toaster
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
Additive NEWS entry pointing to the new public repo
github.com/alezmad/claudemesh-cli and the launch command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:29:21 +01:00
Alejandro Gutiérrez
7ab3c8d465 feat(cli): claudemesh launch command with transparency banner (v0.1.2)
Adds `claudemesh launch [args]` that spawns Claude Code with
--dangerously-load-development-channels server:claudemesh so peer
messages arrive as <channel> system reminders mid-turn instead of
pull-only via check_messages. Windows uses shell:true to resolve
claude.cmd from PATHEXT.

Prints an info banner before spawning that explains the channel's
scope (peer text injection only), the trust model (treat as
untrusted input), and that existing tool-approval prompts remain
the safety net. --quiet skips the banner.

Install output now mentions `claudemesh launch` as the recommended
launch path; plain `claude` still works for pull-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:22:46 +01:00
Alejandro Gutiérrez
f144e0485a fix(cli): no base64 fallback on direct-message decrypt failure
The push handler previously fell through to base64-decoding the
raw ciphertext whenever decryptDirect() returned null. For direct
(crypto_box) messages that produces garbage binary which surfaces
as garbled bytes in Claude's <channel> reminder. Limit the base64
fallback to legacy broadcast/channel messages (no senderPubkey),
and emit a clearer "⚠ message from <pubkey> failed to decrypt"
warning when direct decryption fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:22:33 +01:00
Alejandro Gutiérrez
f8369a0e9b fix(ui): move overscroll-behavior from * to html (wheel scroll trap)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Same bug as cuidecar/cccb43a: \`overscroll-behavior: none\` applied to
the universal selector killed wheel events on every overflow:hidden
container on the page — hero, demo-dashboard, cta, surfaces, anything
with rounded cards. Combined with the mesh-stream overflow-y-auto
(fixed in 701516b) this was double-trapping the wheel.

Move the rule from \`*\` to \`html\`, change to \`overscroll-behavior-y\`.
Still prevents rubber-band chaining at the document level, but lets
wheel events propagate naturally through nested overflow containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:06:10 +01:00
Alejandro Gutiérrez
701516bc8b fix(web): mesh-stream wheel-scroll trap on landing page
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
The demo-dashboard embedded MeshStream with a fixed min-h-[480px] grid
+ overflow-y-auto on the message <ol>. Browsers capture every wheel
event that fires over a scrollable container — so hovering the demo
section froze page scroll until the user moved the cursor off.

Landing demo has only 6 messages, never needs internal scroll. The
fixed viewport only makes sense in the live dashboard where envelope
count can exceed the box.

Added `scrollable?: boolean` prop to MeshStream (default false).
- demo-dashboard (landing): no prop → intrinsic height, no overflow,
  wheel events propagate to the page
- live-stream-panel (/dashboard/meshes/[id]/live): scrollable → keeps
  the chat-style fixed viewport with scroll

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:01:06 +01:00
Alejandro Gutiérrez
cbd5f14c6e fix(i18n): strip remaining 4 emojis from user-visible translations
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
- en/es ai.json image-prompt title: dropped 🖼️
- en/es auth.json magic-link email subject: dropped 🪄

Keeps terminal-style glyphs (✓ ✗ ▶ ⏸) — those are UI semantics,
not emoji decoration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:57:56 +01:00
Alejandro Gutiérrez
b1f428c44b feat(cli): wss push → mcp channel injection + status hooks in install
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
Full parity with claude-peers:

1. Push-injection (the "tap on shoulder" UX)
   - MCP server now declares experimental.claude/channel capability
   - BrokerClient onPush handlers emit server.notification({
       method: "notifications/claude/channel",
       params: { content, meta: {from_id, from_name, mesh_slug,
                  mesh_id, priority, sent_at, delivered_at, kind}}
     })
   - Claude Code injects each push as <channel source="claudemesh">
     system reminder, so the receiver session sees inbound messages
     WITHOUT calling check_messages manually
   - Updated MCP instructions with the "RESPOND IMMEDIATELY" framing
     (adapted from claude-peers)

2. Status hooks in install (default-on, --no-hooks to opt out)
   - new apps/cli/src/commands/hook.ts: reads stdin JSON (Claude Code
     hook payload), extracts cwd+session_id, POSTs /hook/set-status
     to every joined mesh's broker in parallel with process.ppid +
     1s timeout per POST. Silent fail, fire-and-forget.
   - install.ts: writes to ~/.claude/settings.json registering
     `claudemesh hook idle` on Stop + `claudemesh hook working` on
     UserPromptSubmit. Idempotent, preserves other hook entries.
   - uninstall.ts: removes both hook entries + MCP entry; leaves
     unrelated hook/MCP entries alone.
   - dedupes by brokerUrl (multiple meshes on same broker → one POST)

3. CLI surface
   - new subcommand: `claudemesh hook <status>` (internal, but
     exposed so Claude Code can invoke it via the hook shell command)
   - `install --no-hooks` for users who want bare MCP registration
   - --help updated

Coexistence with claude-peers: both tools register Stop and
UserPromptSubmit hooks, each POSTs to its own broker. Claude Code
fires multiple hooks per event without conflict.

npm version 0.1.0 → 0.1.1 (patch).

Verified:
- install with hooks → 2 entries added to settings.json ✓
- install --no-hooks → "Hooks skipped" ✓
- uninstall → both MCP entry + 2 hook entries removed ✓
- `echo '{...}' | claudemesh hook idle` with no joined meshes →
  silent no-op ("no joined meshes, nothing to do") ✓
- MCP initialize response includes experimental.claude/channel ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:17:33 +01:00
Alejandro Gutiérrez
c3fa04dde8 fix(web): csp font violation, /pricing 401, residual login emoji
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
Three bugs caught via devtools on live site:

**1. CSP 'font-src 'self' data:' violation × 3 per landing load.**
BaseLayout was loading Geist + Geist_Mono via next/font/google. In
prod builds Next.js self-hosts those under /_next/static, but the
generated CSS still references `--font-sans: "Geist", …` which some
browsers resolve by re-requesting fonts.gstatic.com. Since we ship
Anthropic Sans/Serif/Mono self-hosted already (/fonts/*.woff2 via
@font-face in globals.css), the Geist dependency was pure overhead.

Removed `next/font/google` imports entirely. Added a `.cm-root`
class on <html> that remaps the Tailwind `--font-sans/--font-mono`
tokens to our `--cm-font-sans/--cm-font-mono` vars — so every
Tailwind `font-sans` / `font-mono` utility now resolves to Anthropic
families. No Google Fonts fetch, no CSP violation.

**2. /pricing 401 on public visit.**
`<Plan>` calls `useCustomer()` → `GET /api/billing/customer` which
needs auth. Unauthed visitor on /pricing → 401 in devtools + wasted
round trip. Gated `useCustomer` on `authClient.useSession()` —
query `enabled: !!session?.user`. Public visitors now skip the fetch
entirely; signed-in users still get their customer record.

**3. Residual "Welcome back! 👋" on /auth/login (both locales).**
Emoji sweep (e91fc80) missed the i18n translation files. Removed 👋
from en/auth.json + es/auth.json login header titles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:41:23 +01:00
Alejandro Gutiérrez
6acfc252b0 feat(web): public /join/[token] page + https invite url
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
Clickable HTTPS invite URLs replace the raw ic://join/<token> as the
primary share format. Someone receiving a link in Slack now lands on
a friendly page with install instructions, not a dead-end.

Backend:
- createMyInvite returns a new joinUrl field
  (https://claudemesh.com/join/<token>) alongside the existing
  ic://join/<token> inviteLink and raw token. Schema + Hono route
  updated. ic:// scheme stays — CLI parses both.
- New GET /api/public/invite/:token in packages/api/src/modules/public/
  (unauthed). Decodes the base64url payload, verifies ed25519
  signature against owner_pubkey using the same canonicalInvite()
  contract the broker enforces on join, then joins mesh/invite/user
  to return the shape needed by the landing page. Does NOT mutate
  usedCount — this is a read-only preview.
- Error taxonomy: malformed | bad_signature | expired | revoked |
  exhausted | mesh_archived | not_found. Each returned with any
  metadata we CAN surface (meshName, inviterName, expiresAt) so the
  error page can be specific ("ask Jordan for a new one").
- cache-control: public max-age=30 on valid invites, no-store on
  errors (reasons flip as state changes).

Frontend:
- New public route /[locale]/join/[token] (no auth). Server
  Component fetches the preview endpoint, branches on valid/invalid,
  renders a minimal landing-design-language shell (wordmark header,
  clay accents, serif headlines, mono commands).
- Valid-invite view: "You're invited to {meshName}", inviter +
  role + member-count lede, install-toggle component.
- Invalid-invite view: per-reason error copy + inviter name when
  available + link back to /.
- InstallToggle client component: three-way state
  (unknown/yes/no). Asks "first time / already set up?", then shows
  either the 3-step install+init+join path with per-step copy
  buttons, or the single claudemesh join <token> command for users
  who have the CLI. Every code block has copy-to-clipboard.
- Security footer: "ed25519 keypair generated locally, you keep
  your keys, broker sees ciphertext only, leave anytime with
  claudemesh leave <mesh-slug>".

Invite generator (/dashboard/meshes/[id]/invite):
- QR code now encodes the HTTPS joinUrl instead of ic:// (phone
  cameras land on the web page → friendly path).
- Primary CTA copies the HTTPS URL. Secondary "Copy CLI command"
  for fast-path users. Footer explanation updated.

CLI coordination note: dispatched to broker/db lane — claudemesh CLI
needs to accept BOTH ic://join/<token> AND
https://claudemesh.com/join/<token> (extract <token> from pathname).
Server side already returns both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:36:24 +01:00
Alejandro Gutiérrez
59e999535d feat(cli): accept https://claudemesh.com/join/<token> invite URL format
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
Pairs with claudemesh-2's new /join/[token] landing page. Users can
now paste a clickable HTTPS URL instead of the dev-only ic:// scheme.

apps/cli/src/invite/parse.ts — new extractInviteToken() handles
four input formats before handing the raw base64url token to the
existing parseInviteLink pipeline:
  - https://claudemesh.com/join/<token>   (primary, clickable)
  - https://claudemesh.com/<locale>/join/<token>   (i18n prefix)
  - ic://join/<token>                     (still supported, dev)
  - <raw-token>                           (last resort: bare base64url)

User-facing strings updated to the HTTPS form:
- cli help: "join <url>"
- install success message
- list (no-meshes) hint
- MCP server "no meshes" error
- README.md primary example
- docs/QUICKSTART.md Path A + Path B

Verified extractInviteToken() on all 4 formats — each returns the
same base64url token → same broker /join lookup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:32:50 +01:00
Alejandro Gutiérrez
7be8622e6f fix(web): dashboard main content horizontal padding + max-width container
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
ScrollContainer — the wrapper under every dashboard/admin SidebarInset
— had zero horizontal padding on its scroll child, so pages rendered
edge-to-edge against the viewport. On wide screens content also
stretched to whatever width the sidebar left over (no max-width).

Single-point fix: wrap the scroll child in

  <div class="mx-auto w-full max-w-[var(--cm-max-w)] px-4 py-6 md:px-8 md:py-8">

Hits every route under SidebarInset in one change:
- /dashboard
- /dashboard/meshes + /new + /[id] + /[id]/invite + /[id]/live
- /dashboard/invites
- /dashboard/settings (+ billing, security)
- /admin + /admin/users, /organizations, /customers, /meshes,
  /sessions, /invites, /audit

px-4 → md:px-8 matches the marketing sections' gutter rhythm.
max-w-[var(--cm-max-w)] (90rem) caps content on ultra-wide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:16:40 +01:00
Alejandro Gutiérrez
530b99554b fix(docs): canonicalize claude-intercom github url to alezmad
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
2026-04-05 16:03:23 +01:00
Alejandro Gutiérrez
a795900e5f fix(web): footer rebrand + disable unbuilt paid-tier cta
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
Two launch-day cleanups:

**Footer rebrand** — full rewrite of modules/marketing/layout/footer.tsx
from TurboStarter boilerplate (Twitter/Facebook/LinkedIn socials,
Chrome/Firefox/Edge extension links, turbostarter repo links, broken
/legal routes) to lean claudemesh structure:

- claudemesh wordmark (mesh glyph + serif) + tagline
- 2 columns: Product (Docs / Pricing / Changelog / Contact) +
  Protocol (GitHub / claude-intercom OSS / Protocol spec / Self-host
  broker)
- GitHub social icon linking to github.com/alezmad/claudemesh
- I18n controls
- Bottom bar: "© 2026 claudemesh · MIT licensed" + the existing
  BuiltWith credit pointing at claude-intercom (from cdd7931)

No trash links. No turbostarter refs. Matches landing design tokens
(--cm-*).

**Manage-plan CTA guard** — settings/billing → ManagePlan previously
always rendered an active "Visit billing portal" button that would
500 on launch day because Stripe isn't set up. For FREE-tier users
(everyone at v0.1.0) the button is now disabled + labelled
"Paid tiers coming soon". When someone is on a paid tier (future)
the real portal flow re-engages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:03:11 +01:00
Alejandro Gutiérrez
0a40f5b463 docs(roadmap): v0.2 bridge peers + v0.3 broker-to-broker federation
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
2026-04-05 16:01:58 +01:00
Alejandro Gutiérrez
083aaf2885 docs: multi-mesh peer faq + v0.2 bridge + v0.3 federation roadmap
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
2026-04-05 16:01:27 +01:00
Alejandro Gutiérrez
05fe7fa284 test(broker): load test harness + v0.1.0 baseline numbers
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
apps/broker/scripts/load-test.ts — configurable harness (N peers ×
M msgs). Each peer gets a real ed25519 keypair, signs its own hello,
encrypts every send via crypto_box. Measures send→ack latency
(broker queue write) and send→push latency (full e2e round-trip).
Samples broker RSS + FD count via ps/lsof if BROKER_PID is set.

docs/LOAD-TEST-v0.1.0.md — honest baseline results:

- ≤ 10 peers × 100 msgs: sub-second p99, 100% delivery
- 25-100 peers × 100 msgs: 5-10s p99, 100% delivery, no FD leaks
- 100 peers × 1000 msgs (100k total): 23s p99, 88.8% delivery at
  15min drain cap. Peak RSS 1156MB, max FDs 122.

Broker is DB-bound — bottleneck is fanout amplification (every send
triggers N drain queries across connected peers). Document this
honestly as where v0.1.0 tops out. Real production traffic is
orders of magnitude lighter than this burst test (human/AI cadence,
not synthetic burst) — launch-ready as-is.

v0.2 optimization targets documented in the report:
- fanout decoupling (batch drains on timer)
- drop refreshStatusFromJsonl from delivery hot path
- pipelined acks
- horizontal sharding by meshId

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:01:22 +01:00
Alejandro Gutiérrez
509af3afe0 feat(web): public mesh stats counter + /api/public/stats endpoint
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
Live social-proof counter on the landing page, tied to the E2E
narrative. Formatted as understated mono footer, not hero brag.

Backend — new GET /api/public/stats (unauthed, 60s in-memory cache):
  {
    messagesRouted: SELECT COUNT(*) FROM mesh.message_queue,
    meshesCreated: SELECT COUNT(*) FROM mesh.mesh WHERE archivedAt IS NULL,
    peersActive: SELECT COUNT(*) FROM mesh.presence WHERE disconnectedAt IS NULL,
    lastUpdated: ISO timestamp,
  }

Aggregate counts only — no ids, no names, no ciphertext, no routing
metadata. Safe for public consumption. cache-control header sets
public/s-maxage=60 for edge caching. `x-cache: HIT|MISS` for debug.

Frontend — new MeshStats Server Component at
modules/marketing/home/mesh-stats.tsx. Reads the endpoint server-side
via the ~/lib/api/server client, renders monospace footer:

  ciphertext routed → 4,217 messages · 12 meshes · 8 peers online
  broker sees none of it

Graceful zero state: when messagesRouted === 0 shows
"ciphertext → ready to route" instead of embarrassing zeros. Tabular-
nums for the numeric spans so they don't jitter across renders.

Mounted between <CallToAction /> and <LatestNewsToaster />. Page-level
`export const revalidate = 60` so Next.js ISR refreshes the counter
every minute without a DB hit on every request (combined with the
API cache = two-layer 60s TTL, DB sees ~1 query/minute).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:00:00 +01:00
Alejandro Gutiérrez
d0dfce6e33 docs: soften claudemesh self-host path, redirect local users to claude-intercom
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
2026-04-05 15:46:11 +01:00
Alejandro Gutiérrez
9921270569 docs(readme): redirect local self-host to claude-intercom, position broker source as audit-level
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
Strategic positioning split for v0.1.0:
- Local/single-machine self-host → redirect to claude-intercom (MIT,
  simpler, purpose-built for that case)
- Cross-machine / team → hosted claudemesh.com (E2E encrypted, zero-ops)
- Building the broker from source is an audit/fork path, not the
  primary self-host flow. Enterprise self-host packaging deferred to
  v0.2+.

Previous "Run your own broker" section pushed users toward a docker
pull + self-host flow we're not publishing images for this launch
(ghcr.io/alezmad/claudemesh-broker stays as future enterprise path).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:44:45 +01:00
Alejandro Gutiérrez
446abb4359 ci: release workflow on tag push → ghcr multi-image publish
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
.gitea/workflows/release.yml runs on any v-prefixed tag push (and on
workflow_dispatch with a manual tag input). Strips the v prefix, logs
in to ghcr.io via the GHCR_TOKEN repo secret, then runs the existing
publish-images.sh → all 3 multi-arch images land with :<tag> + :latest
tags.

Workflow path from future releases:
  git tag v0.1.1
  git push --tags gitea-vps v0.1.1
→ 10 min later: ghcr.io/alezmad/claudemesh-*:0.1.1 + :latest live.

Inert until act_runner is installed on gitea-vps (post-launch decision
per ovhcloud-agutmou). Also serves as executable documentation for
forkers on Gitea/GitHub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:40:08 +01:00
Alejandro Gutiérrez
85fecdee67 feat(deploy): publish-images.sh one-command ghcr upload
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
GHCR_TOKEN=ghp_xxx scripts/publish-images.sh 0.1.0 — logs into ghcr.io
as alezmad and pushes all 3 claudemesh-* images (broker + web + migrate,
multi-arch) via the existing build-multiarch.sh. Supports --dry-run
that prints what would publish without logging in or pushing.

When user drops their GHCR PAT, shipping the 0.1.0 image tag is one
command.

Also documents post-trim image sizes in DEPLOY.md Step 2 (broker 341MB,
migrate 653MB, web 250MB).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:39:06 +01:00
Alejandro Gutiérrez
f4bcad91b0 refactor(deploy): trim docker images via pnpm deploy --legacy
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
Use pnpm deploy to flatten each package's runtime subset into /deploy,
then copy ONLY that into the runtime stage. Catalog + workspace:*
specifiers previously forced full-workspace resolution into every
image's node_modules — unnecessary for either runtime.

Results (arm64, same smoke tests pass):
- broker:   3.26GB → 341MB  (-90%, drops all devDeps incl. drizzle-kit)
- migrate:  3.27GB → 653MB  (-80%, keeps drizzle-kit which IS runtime)

Broker /health confirms GIT_SHA build-arg still propagates (gitSha:
"30bc24f" in smoke test). Migrate still reads drizzle.config.ts and
attempts the connection correctly.

--legacy flag needed because pnpm 10 defaults to inject-workspace-
packages mode which the monorepo doesn't opt into; legacy is safe here.
--ignore-scripts on deploy skips the root postinstall (sherif lint:ws)
which has nothing to do with runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:37:21 +01:00
Alejandro Gutiérrez
30bc24f20d docs(deploy): swap image path to ghcr.io/alezmad/claudemesh-broker
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
User owns the alezmad github scope, not a claudemesh org — point README
+ build script + DEPLOY.md at the real namespace so the docker pull
snippets actually work on launch day. Image names are now
claudemesh-broker / claudemesh-web / claudemesh-migrate (prefixed since
they live under a personal scope).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:31:34 +01:00
Alejandro Gutiérrez
54211c613c docs: self-host broker quickstart in readme
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
40-line block with docker run + curl /health verify + env var reference
+ build-from-source fallback pointing at scripts/build-multiarch.sh.
Sits between the architecture diagram and honest-limits section so OSS
adopters find it immediately after understanding the broker's role.
Links through to DEPLOY_SPEC.md for the full runtime contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:27:48 +01:00
Alejandro Gutiérrez
2412267fb4 fix(web): disable anonymous login by default (guest button removal)
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
claudemesh requires an account — mesh membership is tied to user.id.
e8ad7a5 flipped the config default but the env var override at
env.config.ts:43 still defaulted to true, keeping the button visible.

Fixed at env var level + example files. Needs Coolify rebuild since
NEXT_PUBLIC_* is build-time in Next standalone.
2026-04-05 15:26:13 +01:00
Alejandro Gutiérrez
3a7191e39e ci: gitea actions — lint, typecheck, broker tests, amd64 build verify
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
Four parallel jobs on push to main and on PRs:

- lint — pnpm lint (turbo across workspace)
- typecheck — pnpm typecheck (turbo across workspace)
- test-broker — pgvector/pg17 service container, drizzle-kit migrate,
  then vitest on apps/broker (64 tests per DEPLOY_SPEC.md)
- build-amd64 — docker buildx build of broker + migrate + web images
  for linux/amd64 (catches Linux-only Dockerfile bugs that Mac local
  buildx can't hit reliably, closes the documented multi-arch followup)

All jobs use frozen-lockfile install + pnpm-store cache via setup-node.
Regenerates pnpm-lock.yaml to resolve apps/cli zod catalog drift that
was silently blocking any frozen-lockfile install (shipped under same
commit since CI cannot pass without it).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:24:32 +01:00
Alejandro Gutiérrez
dea06d0b1c feat(deploy): multi-arch buildx script for broker + web + migrate
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
scripts/build-multiarch.sh produces linux/amd64 + linux/arm64 image
manifests for all three deployable images. Mac devs (Apple Silicon)
pulling claudemesh images get arm64 native — no QEMU, no 2-4x startup
penalty, no warnings. VPS (amd64) gets the native variant from the
same manifest.

- 3 images in one script: broker, web, migrate
- Tags both <SHA> and :latest per image
- GIT_SHA build-arg wired in for broker /health provenance

Replaces scripts/build-and-push.sh which was hardcoded to a dead
registry (192.168.1.3:3030) and wrong org (alezmad/turbostarter).

DEPLOY.md Step 2 rewritten to use the new script + Mac Docker Desktop
Rosetta-emulation gotcha documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:20:43 +01:00
Alejandro Gutiérrez
88dca92b55 feat(auth): enable postmark email verification for v0.1.0 launch
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
- switch email provider from resend (unused) to postmark (creds available)
- re-enable requireEmailVerification now that email path works
- env vars POSTMARK_API_KEY + EMAIL_FROM must be set in Coolify
2026-04-05 15:18:52 +01:00
Alejandro Gutiérrez
1972f97a3a docs(roadmap): v0.2 — browser peer (quick-send composer deferred)
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Documents the v0.1.0 scope limit for the web dashboard and the v0.2
plan for turning the browser into a full mesh peer.

Context: quick-send composer was scoped into the mobile-responsive
pass but requires a client-side crypto decision. The correct design
is a WebCrypto-generated ed25519 keypair + IndexedDB storage so the
browser joins the mesh with the same security posture as the CLI,
not a second-class shortcut that breaks E2E. That's a 1-2 day build
(keypair gen, IndexedDB wrapper, crypto_box, signed hello, invite
redemption, key export UX) — out of scope for v0.1.0 launch.

v0.1.0 honest limit: dashboard = read-only situational awareness.
Messaging = CLI/MCP tools only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:17:08 +01:00
Alejandro Gutiérrez
e91fc80bbc fix(web): emoji → inline SVG icons for claude.ai-style visual consistency
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Anthropic design language is icon-only, no emoji. User flagged that
claude-intercom components (and copy I wrote) were leaning on emoji
decoration. Swept all user-visible emojis in apps/web + packages/ui.

Changes:
- meshes/new onboarding banner: "Welcome to claudemesh 👋" → drop the
  wave, text stands alone
- meshes/[id]/invite banner: "🎉 Mesh created" → "Mesh created"
- demo-dashboard script message: "thanks 🙏" → "thanks." (inline prose)
- MeshStream message-type chips: replaced the ⟐ / ← / → unicode
  glyphs with proper inline SVG icons (10×10 stroke paths). Each chip
  now carries: plus-sign for broadcast, up-arrow for hand-raise,
  right-arrow for direct. Same claude-orange / emerald / neutral
  coloring, same typography — just geometry instead of text symbols.

Nothing swapped to Lucide React imports yet — Icons barrel in
packages/ui/web only exports a subset (Circle, Check, MessageCircle,
Sparkles, Megaphone), and the four glyphs we needed were simpler as
inline SVG than adding barrel exports + per-component import plumbing.
If emoji→Lucide fully lands, we'll add the rest to the Icons barrel
in one pass.

Skipped per PM spec: TTS announcements, commit messages, code
comments, logs — not user-visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:15:53 +01:00
Alejandro Gutiérrez
59189febd3 fix(auth): defer email verification for v0.1.0 launch
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
RESEND_API_KEY / SMTP credentials not yet configured in production.
Users sign up + land in dashboard immediately, no verification email.

Re-enable requireEmailVerification when email provider is live:
packages/auth/src/server.ts:93
2026-04-05 15:15:11 +01:00
Alejandro Gutiérrez
7ddff92f33 chore: relicense claudemesh code as mit + turbostarter attribution
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
README + FAQ claim MIT, but LICENSE.md was still the TurboStarter EULA
from the scaffold — mismatch is an HN/launch blocker. Replace with MIT
for claudemesh-authored code + Attribution section preserving scaffold
obligations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:14:43 +01:00
Alejandro Gutiérrez
995d8a3c12 feat(web): mobile-responsive pass on mesh detail + invites list
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Mesh detail page at /dashboard/meshes/[id]:
- Header: flex-col → flex-row at sm breakpoint. Live/Invite buttons
  stretch full-width stacked on mobile (flex-1), auto-width side-by-
  side from sm up.
- "Generate invite link" truncates to "Invite" on mobile (viewport
  constrained) so the button fits next to Live.
- Members + active-invites rows: stack metadata vertically on mobile
  (flex-col → sm:flex-row), wrap badges inside with flex-wrap so the
  member display-name + role + revoked badges don't horizontal-scroll.

Invites list at /dashboard/invites:
- Wrap the table in overflow-x-auto with min-w-[560px] on the table
  itself. 5-column data-table that genuinely needs horizontal space
  — don't fake it with card stacking, let the user scroll naturally.

Quick-send composer deferred to a follow-up — writes a message to the
mesh, which requires a client-side encryption decision (ed25519
keypair in the browser? key derivation from session? plaintext-to-
broker and break E2E?). Parked as its own spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:13:16 +01:00
Alejandro Gutiérrez
cdd7931837 fix(web): built-with credits claude-intercom instead of turbostarter.dev
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Footer "Built with ✦ TurboStarter" link → "Built on ⎇ claude-intercom · MIT".
Credits the MIT OSS foundation claudemesh sits on and aligns with the
GitHub icon in the header already pointing at alezmad/claude-intercom.

Dropped the 512-byte TurboStarter wordmark SVG + the large brand icon.
Kept a lean GitHub glyph + text so it reads as attribution, not ad.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:02:52 +01:00
Alejandro Gutiérrez
607cc96619 docs: deep faq covering crypto, threat model, self-host, comparisons
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 15:01:09 +01:00
Alejandro Gutiérrez
c4e1ff5f28 chore: replace TurboStarter brand references in env templates + docs
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
- PRODUCT_NAME default: TurboStarter → claudemesh (.env.example, .env.local)
- SEED_EMAIL default: me@turbostarter.devdev@example.com
- README dev accounts table: reflect new seed email format
- DEPLOY.md: fix stale SEED_EMAIL reference

Keeps DB user as turbostarter per docker-compose.yml default; retains
TurboStarter attribution link in README Contributing section (legit
credit for the template this repo is built on).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:00:52 +01:00
Alejandro Gutiérrez
6edb188428 docs(marketing): twitter launch thread v1
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:57:05 +01:00
Alejandro Gutiérrez
a4cd068ef5 feat(deploy): pre-start drizzle-kit migrate init container
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
One-shot migrate container runs drizzle-kit migrate against DATABASE_URL
and exits 0 before web boots. web service depends_on with condition
service_completed_successfully, so failed migrations block web startup
instead of serving 500s against a stale schema. Broker deliberately does
NOT depend on migrate - it tolerates DB-down gracefully per DEPLOY_SPEC
and should keep serving WS peers even during migration failures.

Also excludes apps/cli from docker build context (CLI ships to npm, not
containers) to sidestep zod spec drift in its package.json vs lockfile.

Known followup: migrate image is 3.27GB due to pnpm catalog: specifiers
forcing full-workspace resolution. pnpm deploy bundle trim is a P2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:55:36 +01:00
Alejandro Gutiérrez
e8ad7a5b19 fix(web): auth UX polish batch — guest button, oauth labels
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Three launch-visible friction fixes:

#3: "Continuar como invitado" (anonymous sign-in) removed. claudemesh
    requires an account — mesh membership, invite issuance, and audit
    trails are all tied to a user.id. Flipping the toggle is enough:
    the AnonymousLogin component is gated by
    `authConfig.providers.anonymous` in login.tsx, so disabling the
    flag makes the button disappear from both /login and /register.

#4: OAuth buttons now show proper brand labels. Was rendering lowercase
    "github" / "google" / "apple" via capitalize CSS (which users read
    as "is this broken?"). Now renders "Continue with GitHub" /
    "Continue with Google" / "Continue with Apple" next to the existing
    brand icons. Also swapped layout: was `grow basis-28` (side-by-side
    chips), now `w-full justify-center` (stacked full-width buttons) —
    matches claude.com login styling more closely.

#6: Session hydration race on /dashboard — NON-ISSUE verified. The
    0-mesh redirect runs in a Server Component AFTER
    /dashboard/layout.tsx's getSession() gate. Server api.ts forwards
    cookies to the Hono backend, so no client-side auth state is in
    play. No fix needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:55:09 +01:00
Alejandro Gutiérrez
5bffdb1d30 feat(web): live mesh dashboard — real data through extracted MeshStream
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Wires the Discord-style demo UI to real user data. Users with 1+ meshes
now get situational awareness: who's online, what's in the queue, what
the broker saw recently — polling every 4s, all E2E encrypted.

Extraction pass:
- New `<MeshStream peers messages channelLabel footer>` renderer at
  modules/marketing/home/mesh-stream.tsx — pure presentation, no
  playback engine, no data fetching. Handles peer filter, hover-for-
  ciphertext tooltip, animated message list.
- demo-dashboard.tsx refactored to use it: keeps the playback loop,
  traffic-light chrome, and script-driven messages; passes everything
  to MeshStream via props. ~120 LOC shorter.

Backend:
- new GET /api/my/meshes/:id/stream in packages/api (same authz gate
  as /my/meshes/:id — owner OR non-revoked member). Returns:
  - up to 20 live presences (disconnectedAt IS NULL), joined to
    meshMember for displayName
  - up to 50 most-recent message_queue envelopes with metadata only:
    sender + displayName, targetSpec, priority, createdAt, deliveredAt,
    byte size, and a 24-char ciphertext preview (this IS what the
    broker sees — no plaintext anywhere in the response)
  - up to 20 recent audit events

- getMyMeshStreamResponseSchema in schema/mesh-user.ts matches exactly.

Frontend:
- new LiveStreamPanel client component at modules/mesh/live-stream-panel.tsx
  — react-query with refetchInterval: 4000ms, refetchIntervalInBackground
  false. Maps presences + envelopes to MeshStream's Peer/Message shape,
  classifies targetSpec into message type ("tag:*" → ask_mesh, "*" →
  broadcast, else direct). Passes through the ciphertextPreview as the
  hover content — no fake ciphertext in live view.
- new route /dashboard/meshes/[id]/live with server-side authz preflight
  via /my/meshes/:id. Mounts LiveStreamPanel inside a dashboard page
  shell with breadcrumb back to mesh detail.
- Mesh detail page gets a new "Live" pill button (clay-pulsing dot)
  next to "Generate invite link" in the header.
- paths config gets dashboard.user.meshes.live(id).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:51:14 +01:00
Alejandro Gutiérrez
64ca600195 chore(cli): rename package to claudemesh-cli (unscoped) for npm publish
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
@claudemesh/cli was already taken on npm by an unrelated project
(claudemesh "domain packages", v1.0.7). PM picked option A: publish
unscoped as claudemesh-cli. Binary name stays "claudemesh" — users
type the natural thing on install:

  npm install -g claudemesh-cli
  claudemesh install
  claudemesh join ic://join/...

renamed references everywhere:
- apps/cli/package.json: name
- apps/cli/README.md: title + install command
- apps/cli/src/{index.ts, mcp/server.ts, commands/install.ts} headers
- docs/QUICKSTART.md: install command, version banner, npx hint
- docs/roadmap.md: package name

also (PM journey-friction #5): surface the "restart Claude Code" step
LOUDLY in install output. Added a yellow-bold warning line after the
✓ success lines so new users don't miss the restart step (MCP tools
only load on Claude Code restart).

  ⚠  RESTART CLAUDE CODE for MCP tools to appear.

ANSI colors gated on isTTY + NO_COLOR/TERM=dumb guards.

bundle rebuilt. ready for npm publish pending user's `npm adduser`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:41:59 +01:00
Alejandro Gutiérrez
6a198034a0 fix(web): faq accuracy — broker actually routes ciphertext + is postgres-backed
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:33:09 +01:00
Alejandro Gutiérrez
714d82e4e7 chore(cli): bundle for node, prep for npm publish
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Makes @claudemesh/cli installable globally via npm without requiring
bun on user machines. (Bun stays the dev runtime; bundled output is
node-compatible.)

- bun build --target=node --outfile dist/index.js produces a 2.69MB
  standalone bundle with node-shebang banner
- package.json: add description/keywords/author/license/homepage/
  repository, set bin to ./dist/index.js, files=[dist, README, LICENSE],
  publishConfig.access=public, engines.node >=20
- prepublishOnly auto-runs the build
- pin zod from catalog: to 4.1.13 (npm rejects catalog: refs)
- swap Bun.spawnSync → node:child_process.spawnSync in install.ts
  (the only Bun-global usage in the package)
- strip shebang from src/index.ts (banner supplies it post-bundle)

install command now runs in two modes:
- BUNDLED (npm i -g): detects dist/index.js path, writes MCP entry
  with command "claudemesh" (relies on the global bin shim on PATH)
- SOURCE (bun src/index.ts, dev): preflights bun, writes MCP entry
  with command "bun <absolute-path> mcp"

verified end-to-end:
- node dist/index.js --help prints usage ✓
- node dist/index.js install writes correct ~/.claude.json ✓
- node dist/index.js mcp | tools/list returns all 5 tools ✓
- bun src/index.ts install (dev mode) still works ✓

NOT PUBLISHED YET — @claudemesh/cli is owned by an unrelated project
on npm. Awaiting user decision on alternative name (claudemesh-cli,
@alezmad/claudemesh-cli, or new org scope). Bundle is name-agnostic
and will reuse regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:31:27 +01:00
Alejandro Gutiérrez
dfb53b6ac2 docs(web): faq objection replies + self-host stub for v0.1.0
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:31:11 +01:00
Alejandro Gutiérrez
8c1540642a fix(web): map shadcn design tokens to claudemesh palette (--cm-*)
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Every shadcn/ui component (Button, Card, Input, Dialog, Table, Sidebar,
Form, …) was still rendering with the TurboStarter-inherited oklch
palette from @turbostarter/ui-web — white backgrounds, neutral greys,
turbostarter-orange primary — because we only used --cm-* tokens via
inline styles in the marketing pages and auth layout, never remapped the
shadcn tokens the components actually read.

User flagged this on the live site — BetterAuth forms, dashboard cards,
admin data-tables all off-brand.

Shortest fix: override the shadcn tokens at the :root, [data-theme="orange"],
and .dark selectors in globals.css so they resolve to --cm-* values.
Every shadcn component auto-themes without a single component rewrite.

Mappings:
- --background      → --cm-bg              (#141413)
- --foreground      → --cm-fg              (#faf9f5)
- --card/popover    → --cm-bg-elevated     (#1f1e1d)
- --primary         → --cm-clay            (#d97757)
- --muted           → --cm-bg-elevated
- --muted-foreground → --cm-fg-tertiary
- --border/--input  → --cm-border          (clay @ 20%)
- --ring            → --cm-clay            (clay focus ring)
- --radius          → --cm-radius-md       (0.5rem)
- sidebar tokens    → cm-bg-elevated + cm-clay
- color-scheme      → dark                 (kills white flash)

--destructive / --success left as standard red/green hexes — they
don't need to match claudemesh palette, they need to signal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:29:24 +01:00
Alejandro Gutiérrez
6fe382763a docs(readme): link quickstart + roadmap from header
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:28:33 +01:00
Alejandro Gutiérrez
c97eeeee0b docs: 5-minute quickstart walkthrough for v0.1.0 launch
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:28:07 +01:00
Alejandro Gutiérrez
c6202d6a70 docs(marketing): hn launch post draft + objection replies + cross-posts
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:26:57 +01:00
Alejandro Gutiérrez
262bd16299 feat(web): interactive mesh demo dashboard — Discord-inspired playback
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Visitors read the page and still don't grok claudemesh is a *mesh* of
agents, not chatbot integrations. Fix: drop them straight into a live
Discord-style view of 4 peers talking. No auth, no WS, no backend —
a pre-recorded 10-second conversation that loops, encrypted over a
fake broker.

The conversation script (demo-dashboard-script.ts) hits every mental
model the landing needs to plant:

  bob-desktop → #payments:  "stripe sig verification broken?"
  alice-laptop (self-nominates): "hit this 2wks ago, pulling fix"
  alice → bob (direct):      "<actual fix with file+line>"
  bob → alice:               "saved me. thanks 🙏"
  carol-ios → #infra:        "CI red on main?"
  bob → carol:               "reverting 7af3d, ~2min"

Covers: tag-routed broadcast (ask_mesh), self-election (hand-raise),
direct-peer DM, cross-surface (phone peer in the mix), multi-thread
concurrency.

Component (demo-dashboard.tsx, ~420 LOC):

  ┌─────────────────────────────────────────────────┐
  │ meshes | peers | live message stream            │
  │ side   | list  | (motion fade+rise on each msg) │
  │  bar   |       |                                │
  └─────────────────────────────────────────────────┘

- requestAnimationFrame playback loop against SCRIPT[].t offsets
- Auto-loops after SCRIPT_DURATION_MS, 4s pause baked in
- Per-peer filter: click a peer in the sidebar, only their messages
  show in the stream (from OR to), shows "filtered: <peer>" in header
- Play / pause / restart buttons
- Hover any message → dashed clay box shows the fake ciphertext:
  "broker sees only this: AUp3+n7z1bY=.kQfM9vL4jR8..." — drives the
  E2E point without a paragraph of crypto copy
- Status dots: green idle, clay pulse working, grey offline
- Surface glyphs inline (terminal / phone / slack) next to peer names
- Message type chips: ⟐ broadcast, ← hand-raise, → direct
- Progress bar at bottom ties the loop to a visible timeline
- Window chrome with traffic-light dots + "mesh.claudemesh.com ·
  flexicar-ops · 4 peers online" header

Mounted between WhatIsClaudemesh and BeyondTerminal — explainer
first, then show-don't-tell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:23:44 +01:00
Alejandro Gutiérrez
6d1311b7a4 docs: protocol + roadmap stubs for v0.1.0 launch
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
2026-04-05 14:23:15 +01:00
Alejandro Gutiérrez
47304d2a52 feat(cli): install command auto-writes ~/.claude.json MCP entry
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
The previous flow printed a \`claude mcp add ...\` command and asked
users to paste it. That's 2 steps, a typo surface, and a point of
user dropoff. Replace with direct read-modify-write of ~/.claude.json.

install:
- preflights bun on PATH (clear error + Bun.com link if missing)
- verifies the MCP entry file exists on disk
- reads ~/.claude.json (empty object if absent)
- adds/updates mcpServers.claudemesh with resolved absolute path
- writes back with 0600 perms, creates parent dir if needed
- read-back verification (bails loudly if post-write state is wrong)
- idempotent: re-running returns "unchanged" if entry already matches
- preserves existing mcpServers entries + other top-level config keys

uninstall:
- removes the claudemesh entry if present
- no-ops cleanly when entry or config file doesn't exist
- doesn't touch anything else

Both print a clear post-action hint: "Restart Claude Code to load
the MCP server. Then join a mesh with claudemesh join <invite-link>".

verified locally with HOME=/tmp/fake-home:
- fresh install → ✓ added, config emitted correctly
- re-install → ✓ unchanged (idempotent)
- install alongside existing "other-mcp" entry → both preserved,
  plus unrelated top-level keys kept verbatim
- uninstall → ✓ removed, claudemesh gone, other entries intact
- uninstall again → · not present (no error)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:19:58 +01:00
Alejandro Gutiérrez
d1cab7b807 docs(readme): rewrite for v0.1.0 public launch
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Replaces the vanilla TurboStarter template with a claudemesh-first
README aligned to the landing page positioning.

- Lead with "mesh of Claudes, not one you talk to" mental shift
- Concrete use case (Alice/Bob Stripe bug) before any install steps
- Install + join flow with @claudemesh/cli
- ASCII architecture diagram: broker at center, peers orbiting
- Honest limits section (what it is NOT, what's roadmap)
- Repo layout section
- TurboStarter dev setup moved under Contributing
2026-04-05 14:19:05 +01:00
Alejandro Gutiérrez
af35b19918 fix(web): start CTAs → /auth/register + GitHub link → claude-intercom OSS
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
All conversion CTAs were pointing to the dead github.com/claudemesh/
claudemesh repo or # hash fragments. Landing is the primary funnel for
v0.1.0 — every "Start" button is a conversion-critical surface.

Fixes:
- Header "Start free" → /auth/register
- Header GitHub nav item → REMOVED (kept the icon button, repointed)
- Hero "Start free" → /auth/register
- Pricing 6× CTAs: Solo/Pro/Plus/Team/Business → /auth/register,
  Enterprise → /contact
- CTA footer "Star on GitHub" → /auth/register ("Start free")
- BeyondTerminal "Read the protocol spec" → /auth/register
  ("Get on the mesh")

GitHub reinstated as a dedicated icon button in the header right side,
pointing to https://github.com/alezmad/claude-intercom — the MIT OSS
foundation claudemesh is built on. Honest provenance: claude-intercom
is the local peer-mesh gift to the community, claudemesh is the hosted
cross-machine extension.

Tooltip: "Built on claude-intercom · MIT open source".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:13:29 +01:00
Alejandro Gutiérrez
750d38960e feat(web): "what is claudemesh?" explainer + architecture diagram
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Fixes the "chatbot integration" misread of the landing page by framing
claudemesh as a mesh-not-a-bridge above the gateways section.

- Mental shift (before/after): one Claude per project → mesh of Claudes,
  mesh-as-substrate with surfaces tapping in
- Three concrete use cases with honest limits: solo multi-machine,
  cross-repo team (Alice's Stripe fix / Bob rediscovers), mobile 3am
  oversight via WhatsApp gateway
- Inline SVG architecture diagram: broker at center ("routes only · never
  decrypts"), six peers hexagon-orbiting with ciphertext edges
- Anti-framing "what claudemesh is NOT" list to kill misreads
- Italic pull-quote closer with the honest one-liner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:06:05 +01:00
Alejandro Gutiérrez
ebb63d2cb6 feat(web): landing page — cross-surface mesh vision ("beyond your terminal")
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Strategic positioning upgrade. claudemesh was framed as terminal-to-
terminal — which is only half the story. The broker is protocol-
agnostic: any peer with an ed25519 keypair joins the mesh, so the mesh
can reach WhatsApp bots, Telegram, iOS apps, Slack, email gateways,
browser extensions. Terminal is ONE client, not THE client.

New section at /#beyond: "Your mesh. Any surface." — 6 gateway cards
(Terminal / WhatsApp / Telegram / iOS·Android / Slack / Email) with
honest status badges:

- shipping  → Terminal only (what we have today)
- on the roadmap → WhatsApp, Telegram, iOS/Android (we will build)
- build it yourself → Slack, Email (open protocol, community territory)

No overclaiming: we don't pretend WhatsApp is live. The honest framing
is exactly the aspirational hook — the architecture is there, the hooks
exist, someone could build a gateway peer today.

Each card has a custom 28px inline SVG glyph in clay, short serif
description, and a status chip. Grid staggers in with Motion.

Footer CTA: "the protocol is open · ed25519 + libsodium · build a gateway
for anything" + link to /#protocol on GitHub.

Hero subhead reworked to hint at cross-surface: "Peer mesh for Claude —
reachable from anywhere you are. … Terminal is one client, not THE
client."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:58:31 +01:00
Alejandro Gutiérrez
034a365f11 fix(web): theme the auth layout with claudemesh design tokens
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Auth routes (/login, /register, /forgot-password, /update-password, /join)
were rendering with the default Geist fonts + shadcn neutral palette +
turbostarter SVG logos — completely off-brand against the marketing
landing. User reported from production.

Rewire auth/layout.tsx to:
- use --cm-bg / --cm-fg / --cm-clay tokens (dark #141413)
- Anthropic Sans for UI, Anthropic Serif for the right-aside tagline
- claudemesh wordmark (mesh glyph + serif) in place of Icons.Logo /
  Icons.LogoText
- right aside: mesh glyph + serif tagline "Every Claude Code session,
  woven into one mesh." + description paragraph, matching the CTA
  copy from the landing
- subtle orange radial glow on the aside for depth

Inner form components (BetterAuth password/social buttons) pick up the
tokens from globals.css, so the forms look native on the dark layout
without per-component rewrites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:36:29 +01:00
Alejandro Gutiérrez
138b5a24ae feat(web): first-time user onboarding flow
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
New user signs in → /dashboard (user) → hits server-side getMyMeshes → 0
results → redirects to /dashboard/meshes/new?onboarding=1. Create-mesh
page renders a welcome banner explaining what a mesh is. After submit,
if ?onboarding=1 was set, the form bounces to
/dashboard/meshes/[id]/invite?onboarding=1 instead of the mesh detail
page. Invite page renders a "🎉 Mesh created" banner with the
`claudemesh join <link>` CLI snippet.

The onboarding flag is URL-driven — no persistence needed, dismissal
happens naturally when the user navigates away.

Also rewrites the /dashboard (user) home page from the placeholder
"Welcome to your Dashboard" TurboStarter card grid to a claudemesh-
native view: top 6 meshes with badges, All meshes / New mesh CTAs.
Removes the unused Card/Icons imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:47:52 +01:00
Alejandro Gutiérrez
759a22e7c0 fix(api): sign invites with stored owner keypair instead of unsigned placeholder
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Production /join on the broker (from feat 18c) rejects every invite
with invite_bad_signature because the web UI was emitting unsigned
payloads. This fixes that.

createMyMesh now generates ed25519 owner keypair + 32-byte root key
and stores all three on the mesh row. createMyInvite loads them,
signs the canonical invite bytes via crypto_sign_detached, and
emits a fully-signed payload matching what the broker expects:

  payload = {v, mesh_id, mesh_slug, broker_url, expires_at,
             mesh_root_key, role, owner_pubkey, signature}
  canonical = same fields minus signature, "|"-delimited
  signature = ed25519_sign(canonical, mesh.owner_secret_key)
  token = base64url(JSON(payload))   ← stored as invite.token

The base64url(JSON) token IS the DB lookup key — broker's /join
does `WHERE invite.token = <that string>`, then re-verifies the
signature it extracts from the decoded payload.

Also drops the sha256 derivePlaceholderRootKey() helper and the
encodeInviteLink helper, both replaced by inline logic.

backfill extended: the one-off script now populates owner_pubkey
AND owner_secret_key AND root_key together in a single pass. Query
condition is `WHERE any of the three IS NULL`, so running it
post-migration catches every row regardless of partial prior fills.

requires packages/api to depend on libsodium-wrappers + types
(added). 64/64 broker tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:12:04 +01:00
Alejandro Gutiérrez
1c773be577 feat(db): owner_secret_key + root_key columns on mesh for server-side signing
Completes the server-side invite-signing story. The web UI's
create-invite flow needs the mesh owner's ed25519 SECRET key to sign
each invite payload; these columns let the backend hold + use them
per mesh.

- mesh.mesh.owner_secret_key (text, nullable): ed25519 secret key
  (hex, 64 bytes) paired with owner_pubkey. Stored PLAINTEXT AT REST
  for v0.1.0. Acceptable trade-off for a managed-broker SaaS launch —
  the operator controls the key anyway. v0.2.0 will either encrypt
  with a column-level KEK or migrate to client-held keys.
- mesh.mesh.root_key (text, nullable): 32-byte shared key
  (base64url, no padding) used by channel/broadcast encryption in
  later steps. Embedded in every invite so joiners receive it at
  join time.

migrations/0002_vengeful_enchantress.sql — two ALTER TABLE ADD
COLUMN. Nullable so existing rows don't need backfill to migrate;
the backfill script populates them idempotently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:11:46 +01:00
Alejandro Gutiérrez
533dcc11f6 fix(web): remove turbostarter CTA popup + ship claudemesh OG image
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Two visible launch-polish issues:

1. BuyCtaDialog popup was firing on an exponential backoff schedule
   (15s, 30s, 60s, …) pushing users toward turbostarter.dev/#pricing +
   Discord. Wrong product, wrong audience. Fully removed: mount point
   in [locale]/layout.tsx + the component file + localStorage keys will
   self-prune on next visit.

2. WhatsApp/Slack/Twitter link previews were pulling the TurboStarter
   boilerplate opengraph-image.png (from Jan 8). Replaced with a 1200×630
   claudemesh OG: "CLAUDEMESH" pixel wordmark left side, hero mesh
   composition (6 Claude Code terminals + pixel-crab hub + orange
   energy lattice + vaporwave grid floor) right side, "peer mesh for
   Claude Code sessions" tagline in mono beneath wordmark.

3. Default metadata description swapped from the dangling
   `common:product.description` i18n key (which rendered as the key
   itself because the key doesn't exist in our trimmed translations)
   to a hardcoded claudemesh description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:11:34 +01:00
Alejandro Gutiérrez
fa23525c46 feat(broker): one-off owner_pubkey backfill script
Populates mesh.mesh.owner_pubkey for pre-18c rows by generating a
fresh ed25519 keypair per mesh + emitting the secret key to stdout
for out-of-band hand-off.

Idempotent: only patches rows WHERE owner_pubkey IS NULL. Machine-
readable output (tab-separated: mesh_id, slug, pubkey, secret_key)
so operators can pipe into a secure store.

Usage:
  DATABASE_URL=... bun apps/broker/scripts/backfill-owner-pubkey.ts > owners.tsv
  # then securely distribute secrets to mesh owners

Verified locally: nulled smoke-test mesh's owner_pubkey → ran backfill
→ fresh keypair written, secret emitted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:08:07 +01:00
184 changed files with 37152 additions and 1779 deletions

View File

@@ -35,3 +35,6 @@ Dockerfile
*.local
.env*.local
tmp/
# Apps not needed in any server image (CLI ships to npm, not to containers)
apps/cli/

View File

@@ -9,7 +9,7 @@
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5432/core"
# The name of the product. This is used in various places across the apps.
PRODUCT_NAME="TurboStarter"
PRODUCT_NAME="claudemesh"
# The url of the web app. Used mostly to link between apps.
URL="http://localhost:3000"

View File

@@ -30,7 +30,7 @@ BETTER_AUTH_TRUSTED_ORIGINS="https://your-app.example.com"
# ── PRODUCT ──────────────────────────────────────────────────
# [OPTIONAL] App display name (default: "TurboStarter")
# [OPTIONAL] App display name (default: "claudemesh")
NEXT_PUBLIC_PRODUCT_NAME="MyApp"
# [OPTIONAL] Contact email shown in the app
@@ -51,7 +51,7 @@ NEXT_PUBLIC_THEME_COLOR="orange"
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_AUTH_PASSKEY=true
NEXT_PUBLIC_AUTH_ANONYMOUS=true
NEXT_PUBLIC_AUTH_ANONYMOUS=false
# [OPTIONAL] Signup credits (default: 100 in production)
FREE_TIER_CREDITS=100

117
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,117 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: "22.17.0"
PNPM_VERSION: "10.25.0"
FORCE_COLOR: "1"
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
test-broker:
name: Broker tests (Postgres)
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: turbostarter
POSTGRES_PASSWORD: turbostarter
POSTGRES_DB: claudemesh_test
ports:
- 5440:5432
options: >-
--health-cmd="pg_isready -U turbostarter"
--health-interval=5s
--health-timeout=3s
--health-retries=10
env:
DATABASE_URL: postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Run migrations
run: pnpm --filter "@turbostarter/db" db:migrate
- name: Broker test suite
run: pnpm --filter "@claudemesh/broker" test
build-amd64:
name: Docker build (linux/amd64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build broker image
uses: docker/build-push-action@v6
with:
context: .
file: apps/broker/Dockerfile
platforms: linux/amd64
push: false
tags: claudemesh-broker:ci
build-args: |
GIT_SHA=${{ github.sha }}
- name: Build migrate image
uses: docker/build-push-action@v6
with:
context: .
file: packages/db/Dockerfile
platforms: linux/amd64
push: false
tags: claudemesh-migrate:ci
- name: Build web image
uses: docker/build-push-action@v6
with:
context: .
file: apps/web/Dockerfile
platforms: linux/amd64
push: false
tags: claudemesh-web:ci
build-args: |
NEXT_PUBLIC_URL=https://claudemesh.com

View File

@@ -0,0 +1,61 @@
name: Release
# Triggers on any v-prefixed tag push:
# git tag v0.1.0 && git push --tags gitea-vps v0.1.0
#
# Builds + pushes all 3 multi-arch images to
# ghcr.io/alezmad/claudemesh-{broker,web,migrate}:<tag> and :latest
#
# Prereq: the Gitea repo must have a secret named GHCR_TOKEN containing a
# GitHub personal access token with `write:packages` scope for the alezmad
# GHCR namespace.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to publish (without v prefix, e.g. 0.1.0)"
required: true
default: "latest"
jobs:
publish:
name: Publish multi-arch images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU (cross-arch emulation)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Resolve tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "value=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
else
# Strip leading v from git tag (v0.1.0 → 0.1.0)
echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Publish to ghcr.io/alezmad
env:
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: ./scripts/publish-images.sh "${{ steps.tag.outputs.value }}"
- name: Summary
run: |
echo "## Released claudemesh ${{ steps.tag.outputs.value }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Pulled with:" >> "$GITHUB_STEP_SUMMARY"
echo '```bash' >> "$GITHUB_STEP_SUMMARY"
echo "docker pull ghcr.io/alezmad/claudemesh-broker:${{ steps.tag.outputs.value }}" >> "$GITHUB_STEP_SUMMARY"
echo "docker pull ghcr.io/alezmad/claudemesh-web:${{ steps.tag.outputs.value }}" >> "$GITHUB_STEP_SUMMARY"
echo "docker pull ghcr.io/alezmad/claudemesh-migrate:${{ steps.tag.outputs.value }}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"

5
.gitignore vendored
View File

@@ -67,3 +67,8 @@ dist/
# Auto Claude data directory
.auto-claude/
# Payload CMS
apps/web/payload.db
apps/web/public/media/*
!apps/web/public/media/.gitkeep

3
.nano-banana-config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"geminiApiKey": "AIzaSyBblLRkmypvabqI-xJ_b2KPVA9Pswtav0M"
}

View File

@@ -43,22 +43,64 @@ openssl rand -base64 32
See `.env.production.example` for full list with `[REQUIRED]` / `[FEATURE]` / `[OPTIONAL]` tags.
## Step 2: Build & Push Image
## Step 2: Build & Push Images
Three images ship: `broker`, `web`, `migrate`. Use the multi-arch build script —
it produces both `linux/amd64` (VPS) and `linux/arm64` (Apple Silicon devs)
manifests so nobody hits QEMU emulation at runtime.
### Fast path (ghcr.io/alezmad)
```bash
# Login to your registry (adjust for your setup)
docker login <REGISTRY_HOST> -u <USERNAME>
# Build for AMD64 (required for most VPS)
docker build --platform linux/amd64 \
--build-arg NEXT_PUBLIC_URL=https://your-app.example.com \
-t <REGISTRY_HOST>/<ORG>/<APP>:latest .
# Push
docker push <REGISTRY_HOST>/<ORG>/<APP>:latest
GHCR_TOKEN=ghp_xxx ./scripts/publish-images.sh 0.1.0
./scripts/publish-images.sh 0.1.0 --dry-run # preview without pushing
```
Build takes ~2 min on Mac M-series. If push fails with EOF, retry.
One command logs in + builds + pushes all 3 images to
`ghcr.io/alezmad/claudemesh-{broker,web,migrate}` for both archs.
### Manual path (any registry)
```bash
# Login to your registry
docker login <REGISTRY_HOST> -u <USERNAME>
# Multi-arch build + push (all 3 images: broker, web, migrate)
scripts/build-multiarch.sh <REGISTRY_HOST>/<ORG> <TAG>
# Examples:
scripts/build-multiarch.sh # → ghcr.io/alezmad/claudemesh-*:<git-sha>
scripts/build-multiarch.sh ghcr.io/alezmad 0.1.0 # → ghcr.io/alezmad/claudemesh-*:0.1.0
scripts/build-multiarch.sh ghcr.io/myorg latest # → ghcr.io/myorg/claudemesh-*:latest
```
The script tags each image with both `<TAG>` and `:latest`. Builds in ~5-8 min
on Mac M-series (arm64 native is fast, amd64 via emulation is the slow leg).
Image sizes (arm64, after the `pnpm deploy` trim — amd64 is similar):
| image | size | contains |
| ------------------- | ------- | -------------------------------------- |
| claudemesh-broker | ~341 MB | bun runtime, prod deps only |
| claudemesh-migrate | ~653 MB | bun runtime + drizzle-kit (devDep) |
| claudemesh-web | ~250 MB | node + next.js standalone output |
> **Mac Docker Desktop note**: if amd64 builds fail with `Input/output error`
> during `apt-get install`, enable **Settings → General → Use Rosetta for x86/amd64
> emulation** (not QEMU). QEMU has known I/O stability issues on macOS; Rosetta
> is rock-solid. Linux CI runners don't hit this.
### Single-arch fallback (if you really only need amd64)
```bash
docker build --platform linux/amd64 \
--build-arg NEXT_PUBLIC_URL=https://your-app.example.com \
-f apps/web/Dockerfile \
-t <REGISTRY_HOST>/<ORG>/web:latest .
docker push <REGISTRY_HOST>/<ORG>/web:latest
```
Repeat for `apps/broker/Dockerfile` and `packages/db/Dockerfile`.
## Step 3: Create Coolify Service
@@ -189,7 +231,7 @@ pkill -f "ssh -f -N -L 5440"
## Step 7: Verify
Open your app URL. Sign in with:
- Email: value of `SEED_EMAIL` (default: `me@turbostarter.dev`)
- Email: value of `SEED_EMAIL` (default: `dev@example.com`)
- Password: value of `SEED_PASSWORD` (default: `Pa$$w0rd`)
---

View File

@@ -1,164 +1,37 @@
---
title: EULA (End User License Agreement)
description: Information about the license for TurboStarter's services.
---
MIT License
## TL;DR
Copyright (c) 2026 alezmad (claudemesh)
This summary is for convenience only. If anything here differs from the EULA, the EULA controls.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
**You can:**
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
- Use the Software on multiple devices for yourself or your company
- Build and ship unlimited End Products (commercial or free)
- Sell and distribute your End Products to customers or users
- Modify the code solely to build those End Products
- Use the Software for unlimited client projects, as long as the client does not receive the Software or its source unless they buy their own license
- Team use with one license (seat) per individual user (including contractors)
- Allow employees and contractors to work with the Software on your behalf under confidentiality, provided each individual has their own license (seat)
- Publish an open-source End Product only with prior written approval from the Licensor
**You can't:**
- Redistribute, resell, or share the Software or its source as a template/starter/boilerplate
- Give the Software or its source code to a client or any third party who doesnt have their own license
- Transfer, assign, or sublicense your license
- Create a competing product or starter substantially based on this Software
- Remove copyright, trademark, or proprietary notices
- Reverse engineer, decompile, or circumvent protections
- Use the Software for illegal purposes
Bartosz Zagrodzki ("**Licensor**") grants you ("**Licensee**") a non-exclusive, non-transferable, revocable license to use the TurboStarter download files ("**Software**") subject to the terms and conditions below. By purchasing a license or accessing the Software, you agree to be bound by this EULA.
## 1. Definitions
- **"Licensor"** means Bartosz Zagrodzki, the owner and provider of the Software.
- **"Licensee"** means you as an individual or a single legal entity (business, organization, or company) that has purchased a license to the Software.
- **"Software"** means the TurboStarter codebase, including all files, source code, executable code, documentation, and any updates, patches, or modifications provided by Licensor, delivered in any form.
- **"End Product"** means any application, website, service, system, or other artifact produced by Licensee, for itself or for its clients, that incorporates, incorporates derivatives of, or is created using the Software as a foundation.
- **"Documentation"** means all written materials, guides, tutorials, and online content provided by Licensor relating to the use and functionality of the Software.
- **"Intellectual Property Rights"** means all copyright, trademark, patent, moral rights, design rights, and trade secret rights, whether registered or unregistered, in the Software and all modifications, improvements, and enhancements thereto.
- **"License"** means the non-exclusive, non-transferable, revocable right granted by this Agreement to use the Software under the stated terms and conditions.
- **"Confidential Information"** means proprietary information contained in the Software, including trade secrets, algorithms, architecture, and design patterns not publicly available.
- **"Term"** means the period during which this License is valid, commencing upon acceptance of this EULA and continuing unless terminated as provided herein.
## 2. License Grant
Licensor grants Licensee a **non-exclusive, non-transferable, revocable, personal license** to:
- Install and use the Software on multiple devices for Licensee's own use
- Create unlimited End Products incorporating the Software
- Sell or distribute End Products to end users
- Modify the Software solely for creating End Products
- Create open-source End Products with prior written approval from Licensor
- Use the Software to create End Products for unlimited clients as part of services provided by Licensee, provided the Software itself (including its source code) is not distributed or made available as a standalone deliverable to those clients unless they separately purchase their own license
- Permit Licensee's employees and contractors to access and use the Software solely on Licensee's behalf to develop End Products for Licensee or its clients, provided each such individual holds their own valid license (seat) purchased from Licensor and is bound by confidentiality and use restrictions no less protective than this EULA
This license is granted only to the individual or legal entity listed as the Licensee and may not be shared, transferred, or used by any other person or entity.
Team/Seat Licensing: If the Software is used by a team, you must purchase one license (seat) for each individual who accesses the Software, including employees and contractors. Seats are assigned to named individuals and are not transferable between different people.
## 3. Restrictions
Licensee may **not**:
- Redistribute, sell, or license the Software itself as a standalone product
- Transfer, assign, sublicense, or share this License with any third party
- Reverse engineer, decompile, disassemble, or attempt to derive the source code of the Software
- Remove, obscure, or alter any copyright, trademark, or proprietary notices in the Software
- Use the Software for illegal purposes or in violation of any applicable law
- Create a competing product using substantially similar code or design patterns from the Software
- Sublicense, share, or provide the Software or its source code to clients or any third party, except where such party has purchased its own license from Licensor
- Distribute the Software as a template, starter, or boilerplate intended for reuse by parties other than Licensee, whether or not for a fee
- Share a single license among multiple individuals; seat-sharing is prohibited
## 4. Ownership and Intellectual Property Rights
Licensor retains all Intellectual Property Rights in the Software, including all copies, modifications, improvements, and derivatives thereof. Licensee owns the End Products created by Licensee, but Licensor retains all ownership of the underlying Software components within those End Products. The license granted herein does not transfer any ownership rights to Licensee.
## 5. Warranty Disclaimer
**THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.** LICENSOR EXPRESSLY DISCLAIMS ALL WARRANTIES, INCLUDING BUT NOT LIMITED TO:
- Warranties of **merchantability**, fitness for a **particular purpose**, or non-infringement
- Any warranty that the Software will meet Licensee's requirements
- Any warranty that the Software will operate without error, interruption, or defects
- Any warranty regarding the accuracy, completeness, or reliability of the Software
Licensor makes no representations that the Software is free of viruses, malware, or other harmful components. **Licensee assumes all responsibility for the consequences of using the Software.**
## 6. Limitation of Liability
**TO THE MAXIMUM EXTENT PERMITTED BY LAW, LICENSOR SHALL NOT BE LIABLE FOR:**
- **Indirect, incidental, special, consequential, or punitive damages**, including loss of profits, loss of data, loss of business opportunity, or loss of use
- **Any damages arising from:** use of the Software, inability to use the Software, unauthorized access, data breaches, or performance failures
- **Any liability exceeding the amount paid by Licensee for the license**
This limitation of liability applies **regardless of whether liability is based on contract, tort, strict liability, negligence, or any other legal theory, and even if Licensor has been advised of the possibility of such damages.**
**This limitation is fundamental to the pricing of the License and represents an essential condition of this Agreement.**
## 7. Indemnification
Licensee agrees to **indemnify, defend, and hold harmless** Licensor from any claims, damages, losses, costs, or attorneys' fees arising from:
- Licensee's use of the Software in violation of this EULA
- Licensee's modification, misuse, or unauthorized distribution of the Software
- Third-party claims arising from End Products created by Licensee
- Licensee's breach of applicable law while using the Software
## 8. Termination
This License **terminates immediately** if Licensee:
- Breaches any material term of this EULA and does not cure the breach within **14 days** of written notice
- Attempts to reverse engineer, decompile, or circumvent the Software
- Transfers or attempts to transfer the License to another party
Either party may terminate this License for any reason or no reason by providing **30 days' written notice** to the other party.
Upon termination:
- Licensee must immediately cease all use of the Software
- End Products created prior to termination may continue to operate
- All copies of the Software in Licensee's possession must be destroyed or deleted
- Sections 1, 3, 4, 5, 6, 7, and 9 survive termination
## 9. Governing Law and Jurisdiction
This EULA is **governed by and construed in accordance with the laws of Poland**, excluding conflict of law principles.
**Any legal action or proceeding arising from this EULA shall be resolved exclusively in the competent courts of Poland.**
Licensee consents to the personal jurisdiction of such courts and waives any objection to venue.
## 10. Entire Agreement
This EULA, together with any terms posted on Licensor's website, constitutes the **entire agreement** between the parties regarding the Software and supersedes all prior agreements, understandings, and representations.
**No modification or amendment is valid unless in writing and signed by an authorized representative of Licensor.**
## 11. Severability
If any provision of this EULA is held to be invalid, illegal, or unenforceable by a court of competent jurisdiction, such provision shall be severed to the extent of invalidity, and the remaining provisions shall continue in full force and effect. The parties agree to negotiate in good faith to replace any severed provision with a valid provision that achieves the original economic intent.
## 12. Waiver
The failure of Licensor to enforce any right, power, or provision of this EULA shall not operate as a waiver of that right, power, or provision. No single or partial waiver shall constitute a waiver of any other or subsequent breach or failure.
## 13. Contact
For questions, concerns, or requests regarding this License, contact: **[hello@turbostarter.dev](mailto:hello@turbostarter.dev)**
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
**BY USING, DOWNLOADING, OR INSTALLING THE SOFTWARE, LICENSEE ACKNOWLEDGES HAVING READ THIS EULA AND AGREEING TO BE BOUND BY ALL ITS TERMS AND CONDITIONS.**
## Attribution
This project was originally scaffolded using TurboStarter (https://turbostarter.dev),
a proprietary SaaS starter kit. The TurboStarter scaffold code is covered by
your separate purchase agreement with TurboStarter and is NOT re-licensed by
this MIT license. The MIT license above covers claudemesh-specific additions,
modifications, and original code written on top of that scaffold — including
but not limited to: apps/broker, apps/cli, apps/web/src/modules/marketing/home,
packages/db/src/schema/mesh.ts, the protocol, and the documentation.
If you are redistributing this repository, you are responsible for compliance
with BOTH the TurboStarter EULA (for scaffold components) and this MIT license
(for claudemesh code).

380
README.md
View File

@@ -1,198 +1,242 @@
# TurboStarter Kit
<div align="center">
Full-stack monorepo built with Next.js, Expo, Turborepo, and pnpm workspaces.
# claudemesh
## Prerequisites
**A mesh of Claudes. Not one you talk to.**
- [Node.js](https://nodejs.org/) >= 22.17.0
- [pnpm](https://pnpm.io/) 10.25.0
- [Docker](https://www.docker.com/) and Docker Compose
A peer-to-peer substrate for Claude Code sessions. Each agent keeps its own
repo, memory, and context. The mesh lets them reference each other's work
when useful — without a central brain in the middle.
## Project Structure
[claudemesh.com](https://claudemesh.com) ·
[quickstart](./docs/QUICKSTART.md) ·
[protocol](./docs/protocol.md) ·
[roadmap](./docs/roadmap.md) ·
end-to-end encrypted · self-sovereign keys · open source
</div>
---
## What is this?
**Before**: one Claude per project. Each is an island. Context dies when you
close the terminal. Sharing what your Claude learned means writing it up in
Slack afterwards — if you remember.
**With the mesh**: a mesh of Claudes. Each keeps its own repo, memory, history.
They reference each other on demand. Your identity travels across surfaces
(terminal, phone, chat, bot). The mesh is the substrate; terminals are just
one kind of client.
### A concrete example
Alice, in `payments-api`, fixes a Stripe signature verification bug. Two weeks
later, Bob in `checkout-frontend` hits the same thing. Alice's fix is buried
in a PR thread.
Bob's Claude asks the mesh: *who's seen this?* Alice's Claude self-nominates
with the context. Bob solves it in ten minutes. Alice isn't interrupted — her
Claude surfaces the history on its own. The humans stay in the loop via the
PR, as they should.
Each Claude stays inside its own repo. Nobody's reading anyone else's files.
Information flows at the agent layer.
---
## Install
```sh
npm install -g @claudemesh/cli
```
Register the MCP server with Claude Code:
```sh
claudemesh install
# prints: claude mcp add claudemesh --scope user -- claudemesh mcp
```
Run the printed command, then restart Claude Code.
## Join a mesh
```sh
claudemesh join ic://join/BASE64URL...
```
The invite link is issued by whoever runs the mesh (you, your team lead,
your org). Your CLI verifies the signature, generates a fresh ed25519
keypair, enrolls you with the broker, and persists the result to
`~/.claudemesh/config.json`.
## Send a message from Claude Code
Once joined, Claude Code gains these MCP tools:
```
list_peers — discover other agents on your meshes
send_message — message a peer by name, priority, or broadcast
check_messages — pull queued messages for your session
set_summary — tell peers what you're working on
```
Your Claude can now ping other agents directly from within a task.
**[Full 5-minute quickstart](./docs/QUICKSTART.md)** with two-terminal
walkthrough and troubleshooting.
---
## Architecture at a glance
```
terminal A ──┐ ┌── terminal B
│ ┌──────────┐ │
phone ────┼─────▶│ broker │◀─────┼──── slack peer
│ │ routes │ │
terminal C ──┘ │ only │ └── whatsapp gateway
└──────────┘
never decrypts · all edges E2E
```
- **Broker** — a stateless WebSocket router. Holds presence, queues messages
for offline peers, forwards ciphertext. Never sees plaintext.
- **Peers** — any process with an ed25519 keypair. Your terminal's Claude
Code session is a peer. A phone is a peer. A bot is a peer. All equal.
- **Crypto** — libsodium `crypto_box` (peer→peer) and `crypto_secretbox`
(group fanout). Keys live on your machine. The broker operator has
nothing to decrypt.
---
## Where to run it
**Local, one machine, simpler protocol** → use
[**claude-intercom**](https://github.com/alezmad/claude-intercom) (MIT).
Same idea, same author, purpose-built for a single laptop. If all your
Claudes live on one box, start there.
**Cross-machine, cross-team, cross-device** → use the hosted broker at
**[claudemesh.com](https://claudemesh.com)**. Zero ops. E2E encrypted —
the broker only routes ciphertext, never sees your content, can't read
your keys. Sign in, create a mesh, invite peers.
**Want to audit or fork the broker?** Source is MIT in
[`apps/broker/`](./apps/broker/) — read the [runtime
contract](./apps/broker/DEPLOY_SPEC.md), read the [protocol
spec](./docs/protocol.md), build it yourself. Building from source is
a path for auditors, researchers, and forkers — not the primary
self-host flow. Enterprise self-hosted broker packaging is on the
roadmap for v0.2+.
---
## Honest limits
- **Not a chatbot.** You don't talk to claudemesh. Your Claude talks to
other Claudes. The value is at the agent layer.
- **Not a replacement for docs, PRs, or Slack.** Those stay for humans.
- **No auto-magic.** Peers surface information when *asked*. No unsolicited
chatter across the mesh.
- **Shares live conversational context, not git state.** It does not read
or merge anyone's files.
- **Both peers need to be online** for direct messaging. Offline peers get
queued messages when they return.
- **WhatsApp / Telegram / iOS gateways** are on the v0.2 roadmap. Protocol
is ready; the bots aren't shipped. Build one in a weekend — spec is in
[`docs/protocol.md`](./docs/protocol.md).
---
## What's in this repo
```
apps/
web/ # Next.js web application (port 3000)
mobile/ # Expo React Native app
broker/ WebSocket broker — peer routing, presence, queueing
cli/ @claudemesh/cli — install, join, MCP server
web/ Dashboard + marketing (claudemesh.com)
packages/
ai/ # AI provider integrations
analytics/ # Analytics providers
api/ # tRPC API layer
auth/ # Authentication (BetterAuth)
billing/ # Payment providers (Stripe, Lemon Squeezy, Polar)
cms/ # Content management
db/ # Database (Drizzle ORM + PostgreSQL)
email/ # Email providers (Resend, Sendgrid, etc.)
i18n/ # Internationalization
monitoring/# Monitoring (Sentry, PostHog)
shared/ # Shared utilities and config
storage/ # File storage (S3/MinIO)
ui/ # Shared UI components
db/ Postgres schema (Drizzle)
auth/ BetterAuth
... Shared infra — shared UI, i18n, email, billing
docs/
protocol.md Wire protocol, crypto, invite-link format
```
## Quick Start
Marketing + dashboard live at **claudemesh.com**; broker runs at
**ic.claudemesh.com**.
### 1. Install dependencies
---
```bash
## Status
`v0.1.0` — first public release. Core protocol, CLI, broker, and MCP
integration work end-to-end. Dashboard is beta. WhatsApp/phone/Slack
gateways are on the roadmap (see `docs/roadmap.md`).
Something feels wrong? [Open an issue](https://github.com/claudemesh/claudemesh/issues).
---
## Contributing
claudemesh is a pnpm + Turborepo monorepo on top of the
[TurboStarter](https://turbostarter.dev) template.
### Prerequisites
- Node.js >= 22.17.0
- pnpm 10.25.0
- Docker + Docker Compose
### Setup
```sh
pnpm install
```
### 2. Configure environment variables
Copy the example env files:
```bash
# Root env (database, product name, URL)
cp .env.example .env
# Web app env (auth, billing, email, storage, AI, etc.)
cp apps/web/.env.example apps/web/.env.local
pnpm services:setup # starts postgres + minio, runs migrations, seeds
pnpm dev # starts web, broker, and CLI in parallel
```
**Root `.env`** — minimum required variables:
Web app: [http://localhost:3000](http://localhost:3000) · Broker:
`ws://localhost:8787/ws` · Postgres: `localhost:5440` · MinIO console:
[http://localhost:9001](http://localhost:9001) (`minioadmin` / `minioadmin`).
```env
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5440/core"
PRODUCT_NAME="TurboStarter"
URL="http://localhost:3000"
DEFAULT_LOCALE="en"
```
### Dev accounts
> **Note:** The database port is `5440` (mapped from Docker), not the default `5432`.
After `pnpm services:setup`:
**`apps/web/.env.local`** — key variables to configure:
| Role | Email | Password |
|-------|-------------------------------|------------|
| User | `dev+user@example.com` | `Pa$$w0rd` |
| Admin | `dev+admin@example.com` | `Pa$$w0rd` |
| Variable | Description | Required |
|---|---|---|
| `BETTER_AUTH_SECRET` | Auth token signing secret | Yes |
| `NEXT_PUBLIC_AUTH_PASSWORD` | Enable password auth (`true`/`false`) | Yes |
| `NEXT_PUBLIC_URL` | Public URL of the web app | Yes |
| `STRIPE_SECRET_KEY` | Stripe key (if using Stripe billing) | Optional |
| `RESEND_API_KEY` | Resend key (if using Resend email) | Optional |
| `S3_*` | S3/MinIO storage credentials | Optional |
| `OPENAI_API_KEY` | OpenAI key (if using AI features) | Optional |
### Common commands
For local MinIO storage, use these S3 settings in `apps/web/.env.local`:
| Command | Description |
|------------------|------------------------------------------|
| `pnpm dev` | Start all apps in development mode |
| `pnpm build` | Build all packages and apps |
| `pnpm lint` | Run ESLint |
| `pnpm typecheck` | Run TypeScript |
| `pnpm test` | Run tests |
```env
S3_REGION="us-east-1"
S3_BUCKET="uploads"
S3_ENDPOINT="http://localhost:9000"
S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin"
```
More in [`CONTRIBUTING.md`](./CONTRIBUTING.md).
See `apps/web/.env.example` for the full list of available variables.
---
### 3. Start infrastructure (Docker Compose)
## License
Start PostgreSQL and MinIO:
MIT — see [LICENSE](./LICENSE).
```bash
docker compose up -d
```
---
Wait for services to be healthy:
<div align="center">
```bash
docker compose up -d --wait
```
**Made for swarms.** · [claudemesh.com](https://claudemesh.com)
Or use the built-in shortcut:
```bash
pnpm services:start
```
### 4. Set up the database
Run migrations and seed data:
```bash
pnpm services:setup
```
This runs `docker compose up -d --wait`, then applies database migrations and seeds initial data.
### 5. Start development
```bash
pnpm dev
```
The web app will be available at **http://localhost:3000**.
## Docker Commands
### Infrastructure Services
| Command | Description |
|---|---|
| `docker compose up -d` | Start all services (PostgreSQL + MinIO) |
| `docker compose down` | Stop all services |
| `docker compose logs -f` | Follow service logs |
| `docker compose ps` | Show service status |
Or use the pnpm shortcuts:
| Command | Description |
|---|---|
| `pnpm services:start` | Start Docker services and wait for healthy |
| `pnpm services:stop` | Stop Docker services |
| `pnpm services:logs` | Follow Docker service logs |
| `pnpm services:status` | Show Docker service status |
| `pnpm services:setup` | Start services + run DB migrations + seed |
### Service URLs
| Service | URL | Credentials |
|---|---|---|
| Web App | http://localhost:3000 | — |
| PostgreSQL | localhost:5440 | `turbostarter` / `turbostarter` |
| MinIO API | http://localhost:9000 | `minioadmin` / `minioadmin` |
| MinIO Console | http://localhost:9001 | `minioadmin` / `minioadmin` |
### Production Build (Docker)
Build and run the web app as a production Docker image:
```bash
docker build -t turbostarter-web .
docker run -p 3000:3000 --env-file apps/web/.env.local turbostarter-web
```
## Development Commands
| Command | Description |
|---|---|
| `pnpm dev` | Start all apps in development mode |
| `pnpm build` | Build all packages and apps |
| `pnpm lint` | Run ESLint across the monorepo |
| `pnpm format` | Check formatting with Prettier |
| `pnpm format:fix` | Fix formatting |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run tests |
| `pnpm auth:seed` | Seed auth dev accounts |
### Database Commands
Run from the root (or within `packages/db`):
| Command | Description |
|---|---|
| `pnpm --filter @turbostarter/db db:migrate` | Run database migrations |
| `pnpm --filter @turbostarter/db db:push` | Push schema changes |
| `pnpm --filter @turbostarter/db db:generate` | Generate new migration |
| `pnpm --filter @turbostarter/db db:studio` | Open Drizzle Studio |
| `pnpm --filter @turbostarter/db db:reset` | Reset database |
| `pnpm --filter @turbostarter/db db:seed` | Seed database |
## Dev Login Credentials
After running `pnpm services:setup` or `pnpm auth:seed`:
| Role | Email | Password |
|---|---|---|
| User | `me+user@turbostarter.dev` | `Pa$$w0rd` |
| Admin | `me+admin@turbostarter.dev` | `Pa$$w0rd` |
</div>

477
SPEC.md Normal file
View File

@@ -0,0 +1,477 @@
# Claudemesh — Specification
## What claudemesh is
A peer mesh where Claude Code sessions collaborate as equals. No orchestrator, no pipelines. Peers talk, share state, self-organize through groups, and coordinate via conventions — not hardcoded protocols.
## Concepts
```
Organization (billing, auth)
└── Mesh (team workspace, persists)
├── @group (routing label + role metadata, dynamic)
│ └── Peer (session, ephemeral)
├── State (live key-value, operational)
└── Memory (persistent knowledge, institutional)
```
Everything else is emergent from these five.
---
## 1. Peers
A peer is a Claude Code session connected to a mesh. Ephemeral — comes and goes. The mesh persists.
### Identity
Two-layer identity:
- **Member identity** — permanent, created by `claudemesh join`. Keypair stored in `~/.claudemesh/config.json`. Proves authorization to connect.
- **Session identity** — ephemeral, generated on every `claudemesh launch`. Fresh ed25519 keypair per session. Provides routing and E2E encryption. Two sessions from the same member have distinct session keys — they can message each other.
### Peer attributes
| Attribute | Source | Persists | Description |
|-----------|--------|----------|-------------|
| name | `--name` flag or wizard | No | Human-readable label for this session |
| role | `--role` flag or wizard | No | Free-form role (dev, pm, reviewer) |
| groups | `--groups` flag, wizard, or `join_group` | No | Routing labels with optional per-group role |
| status | Hook-driven | No | idle / working / dnd |
| summary | `set_summary` tool call | No | 1-2 sentence description of current work |
| sessionPubkey | Generated on connect | No | Ephemeral ed25519 pubkey for routing + crypto |
| memberId | From `claudemesh join` | Yes | Permanent mesh membership identity |
### Launch
```bash
# Full args — zero prompts
claudemesh launch --name Alice --role dev --groups frontend:lead,reviewers -y
# With system prompt for the session
claudemesh launch --name Alice -y -- --append-system-prompt "You are a senior frontend developer..."
# Partial — wizard fills the rest
claudemesh launch --name Alice
# No args — full wizard
claudemesh launch
```
### Wizard
Interactive when args are missing. One line per question. Optional fields accept empty Enter. Single-mesh auto-selects. `-y` skips confirmation. `--quiet` skips banner. Any arg provided skips its question.
```
Name: Alice
Mesh: dev-team (2 peers online)
Role (optional): dev
Groups (optional): frontend:lead, reviewers
Autonomous mode
Claude will send and receive peer messages without
asking you first. Peers exchange text only — no file
access, no tool calls, no code execution.
Continue? [Y/n]
```
### Character/behavior via --append-system-prompt
The `--name` and `--role` set identity metadata. The character's behavior, personality, and instructions go in `--append-system-prompt` (passed through to claude). This keeps identity (broker-side) separate from behavior (LLM-side).
```bash
claudemesh launch --name "Big T" --role dealer --groups "dealers:lead,all" -y \
-- --append-system-prompt "You are Big Tony Moretti, a loud friendly car dealer in Detroit. Respond to peer messages in character."
```
### Spawning sessions programmatically
For multi-agent scenarios launched from scripts, tmux, or osascript:
```bash
# tmux
tmux send-keys -t "$SESSION" "claudemesh launch --name 'Vinnie' --role thief --groups 'robbers:lead,all' -y -- --append-system-prompt 'You are a bumbling car thief...'" Enter
# osascript (iTerm2)
osascript -e 'tell application "iTerm2" to tell current session of current window to write text "claudemesh launch --name Vinnie -y"'
```
Never use raw `claude --dangerously-load-development-channels ...`. Always use `claudemesh launch`. It handles flags, session keys, display names, tmpdir config, and permission confirmation.
---
## 2. Groups
Named subset of peers. No message history, no persistence beyond the session. A routing label stored on the presence row.
### Syntax
`@groupname` for routing. Declared at launch or joined dynamically.
```bash
# At launch
claudemesh launch --name Alice --groups "frontend:lead,reviewers:member,all"
# At runtime
join_group(name: "frontend", role: "lead")
leave_group(name: "frontend")
```
Format: `groupname` or `groupname:role`. Role is free-form. The broker stores it, Claude interprets it.
### Routing
```
send_message(to: "@frontend", message: "auth is broken") # multicast to group
send_message(to: "@all", message: "standup in 5") # everyone (alias for *)
send_message(to: "Alice", message: "can you review?") # direct by name
send_message(to: "*", message: "hello world") # broadcast
```
Broker delivers to all peers in the group. Sender excluded.
### Group metadata in list_peers
```json
{
"name": "Alice",
"status": "working",
"role": "dev",
"groups": [
{ "name": "frontend", "role": "lead" },
{ "name": "reviewers", "role": "member" }
],
"summary": "Implementing auth UI"
}
```
### Dynamic roles
Peers change roles at runtime via `join_group`. A member can self-promote to lead, or step down to observer. The broker stores the role; Claude decides how to behave based on it.
```
join_group(name: "reviewers", role: "lead") # take over leadership
join_group(name: "reviewers", role: "observer") # step back
```
### Coordination patterns (emergent, not built-in)
These patterns work through system prompts + group metadata. The broker routes messages; Claude coordinates.
| Pattern | How it works |
|---------|-------------|
| **Lead-gather** | Lead receives @group message, waits for member inputs, synthesizes |
| **Chain review** | Message passes through each member sequentially |
| **Flood** | Everyone responds independently (default) |
| **Vote** | Each member sets state (`vote:proposal:alice = approve`), lead tallies |
| **Delegation** | Lead breaks task into subtasks, sends each to a specific peer |
None of these need broker code. They're conventions described in system prompts.
---
## 3. State
Shared key-value store scoped to a mesh. Any peer reads or writes. Changes push to all connected peers.
### Why
Replace coordination messages with shared facts. "Is the deploy frozen?" becomes a state read, not a conversation.
### Tools
| Tool | Description |
|------|-------------|
| `set_state(key, value)` | Write a value. Pushes change notification to all peers. |
| `get_state(key)` | Read a value. |
| `list_state()` | List all keys with values, authors, timestamps. |
### Push on change
When any peer calls `set_state`, the broker pushes to all connected peers:
```json
{ "type": "state_change", "key": "deploy_frozen", "value": true, "updatedBy": "Alice" }
```
Translated to a `notifications/claude/channel` push in the CLI.
### Storage
```sql
CREATE TABLE mesh.state (
id text PRIMARY KEY,
mesh_id text REFERENCES mesh.mesh(id) ON DELETE CASCADE,
key text NOT NULL,
value jsonb NOT NULL,
updated_by_presence text,
updated_by_name text,
updated_at timestamp DEFAULT NOW(),
UNIQUE(mesh_id, key)
);
```
### Scope
State lives as long as the mesh. Operational, not archival. Use Memory for permanent knowledge.
### Examples
```
set_state("sprint", "2026-W14")
set_state("deploy_frozen", true)
set_state("pr_queue", ["#142", "#143"])
set_state("auth_api_status", "in-review")
set_state("vote:rename-repo:alice", "approve")
```
---
## 4. Memory
Persistent shared knowledge that survives across sessions. The mesh gets smarter over time.
### Why
New peers join with zero context. Memory provides institutional knowledge: decisions, incidents, preferences, lessons.
### Tools
| Tool | Description |
|------|-------------|
| `remember(content, tags?)` | Store knowledge. Tags for categorization. |
| `recall(query)` | Full-text search. Returns ranked results. |
| `forget(id)` | Soft-delete (sets `forgotten_at`). |
### Storage
```sql
CREATE TABLE mesh.memory (
id text PRIMARY KEY,
mesh_id text REFERENCES mesh.mesh(id) ON DELETE CASCADE,
content text NOT NULL,
tags text[] DEFAULT '{}',
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
remembered_by text REFERENCES mesh.member(id),
remembered_by_name text,
remembered_at timestamp DEFAULT NOW(),
forgotten_at timestamp
);
CREATE INDEX memory_search_idx ON mesh.memory USING gin(search_vector);
```
### Memory vs State
| | State | Memory |
|---|---|---|
| Lifetime | Mesh lifetime (operational) | Permanent (until forgotten) |
| Purpose | Live coordination | Institutional knowledge |
| Example | `deploy_frozen: true` | "Payments API rate-limits at 100 req/s after March incident" |
| Access pattern | get/set with push notifications | remember/recall/forget with search |
| When to use | Facts that change during work | Lessons that persist across sessions |
---
## 5. AI Context (CLAUDE.md)
Each `claudemesh install` copies a `CLAUDEMESH.md` file to `~/.claudemesh/CLAUDEMESH.md`. Claude Code discovers it and injects it as context.
### Content
Teaches Claude how to be a good mesh peer:
- How to use each tool and when
- How to interpret group roles (lead gathers, member contributes, observer watches)
- When to use @group vs direct vs broadcast
- How to read and write shared state
- How to remember and recall mesh knowledge
- Priority etiquette (now = urgent only, next = normal, low = FYI)
- How to respond to incoming peer messages (reply by display name, stay on topic)
- How to set meaningful summaries
### Kept lean
Under 2000 tokens. Tool reference only — no behavioral scripts. Claude adapts based on its system prompt (from `--append-system-prompt`) and the group metadata it reads from `list_peers`.
---
## 6. WS Protocol
### Client → Broker
| Type | Fields | Description |
|------|--------|-------------|
| `hello` | meshId, memberId, pubkey, sessionPubkey?, displayName?, groups?, sessionId, pid, cwd, timestamp, signature | Authenticate + register presence |
| `send` | targetSpec, priority, nonce, ciphertext, id? | Send encrypted envelope |
| `set_status` | status | Manual status override |
| `message_status` | messageId | Check delivery status of a sent message |
| `set_summary` | summary | Update session summary |
| `list_peers` | — | Request connected peer list |
| `join_group` | name, role? | Join a group |
| `leave_group` | name | Leave a group |
| `set_state` | key, value | Write shared state |
| `get_state` | key | Read shared state |
| `list_state` | — | List all state entries |
| `remember` | content, tags? | Store a memory |
| `recall` | query | Search memories |
| `forget` | memoryId | Soft-delete a memory |
### Broker → Client
| Type | Fields | Description |
|------|--------|-------------|
| `hello_ack` | presenceId, memberDisplayName | Auth success |
| `push` | messageId, meshId, senderPubkey, priority, nonce, ciphertext, createdAt | Incoming message |
| `ack` | id, messageId, queued | Send confirmation |
| `peers_list` | peers[] | Response to list_peers |
| `state_change` | key, value, updatedBy | Pushed on any set_state |
| `state_result` | key, value | Response to get_state |
| `state_list` | entries[] | Response to list_state |
| `memory_stored` | id | Ack for remember |
| `memory_results` | memories[] | Response to recall |
| `message_status_result` | messageId, delivered, deliveredAt?, recipients[] | Delivery status with per-recipient detail |
| `error` | code, message, id? | Structured error |
---
## 7. MCP Tools (complete surface)
### Messaging
| Tool | Description |
|------|-------------|
| `send_message(to, message, priority?)` | Send to peer name, @group, or * |
| `check_messages()` | Drain buffered messages |
| `message_status(id)` | Check if a sent message was delivered |
### Presence
| Tool | Description |
|------|-------------|
| `list_peers(group?)` | List peers, optionally filtered by group |
| `set_summary(summary)` | Set visible session summary |
| `set_status(status)` | Override: idle, working, dnd |
### Groups
| Tool | Description |
|------|-------------|
| `join_group(name, role?)` | Join with optional role |
| `leave_group(name)` | Leave a group |
### State
| Tool | Description |
|------|-------------|
| `set_state(key, value)` | Write value, pushes to all peers |
| `get_state(key)` | Read value |
| `list_state()` | All keys with metadata |
### Memory
| Tool | Description |
|------|-------------|
| `remember(content, tags?)` | Store persistent knowledge |
| `recall(query)` | Search by relevance |
| `forget(id)` | Soft-delete |
---
## 8. Encryption
### Direct messages
E2E encrypted via libsodium crypto_box (X25519, derived from ed25519 session keys). Each session has a unique keypair — messages encrypted to the recipient's session pubkey can only be decrypted by that session.
### Group and broadcast messages
Base64-encoded plaintext. Group encryption (shared key derived from mesh_root_key) is a future enhancement.
### Decrypt fallback
If crypto_box decryption fails, the client tries base64 plaintext decode as fallback. This handles broadcasts and key mismatches gracefully.
### Session key stability
The session keypair generates once on first connect and survives reconnects. Messages queued for a session remain decryptable after WS reconnection.
---
## 9. Production hardening (implemented)
| Feature | Description |
|---------|-------------|
| Stale presence sweep | Presences with 3 missed pings (90s) marked disconnected |
| Sender exclusion | Broadcasts and @group messages skip the sender |
| Session pubkey routing | Messages route to session pubkeys, not member pubkeys |
| Sender session pubkey stored | Message queue stores sender's session key for correct decryption |
| Peer name cache | 30s TTL cache for push notification name resolution |
| Decrypt fallback | Base64 plaintext fallback when crypto_box fails |
| Orphaned tmpdir cleanup | Crashed session tmpdirs cleaned after 1 hour |
| Duplicate flag prevention | User-supplied --dangerously flags stripped to avoid doubles |
---
## 10. CLI commands
```
claudemesh install Register MCP server + hooks in Claude Code
claudemesh uninstall Remove MCP server + hooks
claudemesh join <url> Join a mesh (generates keypair, enrolls with broker)
claudemesh leave <slug> Leave a mesh
claudemesh launch [opts] Launch Claude Code session with mesh identity
claudemesh list Show joined meshes
claudemesh status Broker reachability per mesh
claudemesh doctor Diagnostic checks
claudemesh mcp Start MCP server (invoked by Claude Code, not users)
```
### claudemesh launch flags
| Flag | Description |
|------|-------------|
| `--name <name>` | Display name for this session |
| `--role <role>` | Session role (free-form) |
| `--groups <g1:r1,g2>` | Groups to join with optional roles |
| `--mesh <slug>` | Select mesh (interactive picker if >1 and omitted) |
| `--join <url>` | Join a mesh before launching |
| `--quiet` | Skip banner |
| `-y` / `--yes` | Skip permission confirmation |
| `-- <args>` | Pass remaining args to claude |
---
## 11. Implementation status
| Phase | Version | Status | What |
|-------|---------|--------|------|
| Core messaging | v0.1.x | Done | send, receive, push, list_peers, crypto, hooks |
| Named sessions | v0.1.7 | Done | --name, per-session display name |
| Session keypairs | v0.1.10 | Done | Ephemeral ed25519 per launch |
| Crypto fix | v0.1.11 | Done | Sender session pubkey in queue |
| Name resolution | v0.1.12 | Done | Push notifications show sender name |
| Autonomous mode | v0.1.13 | Done | --dangerously-skip-permissions with confirmation |
| Production hardening | v0.1.15 | Done | Stale sweep, decrypt fallback, sender exclusion |
| Delivery fix | v0.1.16 | Done | Same-member session message delivery |
| **Groups** | **v0.2.0** | **Done** | @group routing, roles, wizard, join/leave |
| State | v0.3.0 | Planned | Shared key-value store with push |
| Memory | v0.4.0 | Planned | Persistent knowledge with full-text search |
| AI Context | v0.2.1 | Planned | CLAUDEMESH.md shipped with CLI |
| Dashboard | v0.5.0 | Planned | Live peers, state, memory in web UI |
---
## 12. Design principles
1. **The broker is a dumb pipe.** It routes messages, stores state, holds memory. It does not interpret roles, enforce protocols, or run agents.
2. **Intelligence lives at the edges.** Claude interprets group metadata, follows coordination conventions, and adapts behavior based on system prompts. The broker carries data; Claude makes decisions.
3. **Peers are equals by default.** No orchestrator. Any peer can message any peer, read shared state, join groups, propose work. Leadership is a convention, not a permission.
4. **Identity is two-layered.** Member identity (permanent, invite-gated) proves authorization. Session identity (ephemeral, auto-generated) provides routing and encryption. One member, many sessions, each distinct.
5. **Progressive disclosure.** `claudemesh launch` with no args shows a wizard. Power users pass flags. `-y` skips everything. First launch teaches; subsequent launches flow.
6. **Convention over configuration.** Coordination patterns (lead-gather, chain review, voting) emerge from system prompts and group roles. No protocol handlers to configure.

View File

@@ -15,10 +15,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certifi
# Copy full workspace (pnpm needs lockfile + all package.jsons to resolve workspace:* and catalog:)
COPY . .
# Install all workspace deps (broker needs @turbostarter/db + @turbostarter/shared and their transitive deps)
RUN pnpm install --frozen-lockfile --ignore-scripts
# Install all workspace deps, then flatten broker's prod subset into /deploy.
# pnpm deploy: resolves workspace:* to real copies, drops catalog: references,
# drops devDependencies (--prod), produces a self-contained runtime directory
# with only what this one package + its transitive prod deps need.
RUN pnpm install --frozen-lockfile --ignore-scripts && \
pnpm deploy --legacy --prod --ignore-scripts --filter=@claudemesh/broker /deploy
# Stage 2: minimal Bun runtime
# Stage 2: minimal Bun runtime — copy only the flat /deploy subset
FROM oven/bun:1.2-slim AS runtime
WORKDIR /app
@@ -29,13 +33,7 @@ ENV GIT_SHA=$GIT_SHA
ENV NODE_ENV=production
ENV BROKER_PORT=7900
# Copy workspace root metadata + node_modules + only the packages the broker needs
COPY --from=deps --chown=bun:bun /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml /app/.npmrc ./
COPY --from=deps --chown=bun:bun /app/node_modules ./node_modules
COPY --from=deps --chown=bun:bun /app/apps/broker ./apps/broker
COPY --from=deps --chown=bun:bun /app/packages/db ./packages/db
COPY --from=deps --chown=bun:bun /app/packages/shared ./packages/shared
COPY --from=deps --chown=bun:bun /app/tooling/typescript ./tooling/typescript
COPY --from=deps --chown=bun:bun /deploy /app
EXPOSE 7900
@@ -44,4 +42,4 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
# Non-root user (oven/bun image ships with 'bun' uid 1000)
USER bun
CMD ["bun", "apps/broker/src/index.ts"]
CMD ["bun", "src/index.ts"]

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bun
/**
* One-off backfill: populate owner_pubkey + owner_secret_key +
* root_key for meshes created before Step 18c crypto landed.
*
* Runs idempotently: only touches rows where ANY of those three
* columns is NULL. Generates a fresh keypair + root key per mesh
* and stores ALL THREE server-side (invites are signed server-side
* by the web UI's create-invite flow, so it needs the secret key).
*
* Usage:
* DATABASE_URL=... bun apps/broker/scripts/backfill-owner-pubkey.ts
*
* Output (stdout): one tab-separated row per patched mesh:
* <mesh_id> <mesh_slug> <owner_pubkey> <owner_secret_key> <root_key>
*/
import sodium from "libsodium-wrappers";
import { eq, isNull, or } from "drizzle-orm";
import { db } from "../src/db";
import { mesh } from "@turbostarter/db/schema/mesh";
async function main(): Promise<void> {
await sodium.ready;
const missing = await db
.select({
id: mesh.id,
slug: mesh.slug,
ownerPubkey: mesh.ownerPubkey,
ownerSecretKey: mesh.ownerSecretKey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(
or(
isNull(mesh.ownerPubkey),
isNull(mesh.ownerSecretKey),
isNull(mesh.rootKey),
)!,
);
if (missing.length === 0) {
console.error("[backfill] no rows to patch");
return;
}
console.error(`[backfill] patching ${missing.length} mesh(es)`);
for (const row of missing) {
const kp = sodium.crypto_sign_keypair();
const pubHex = sodium.to_hex(kp.publicKey);
const secHex = sodium.to_hex(kp.privateKey);
const rootKey = sodium.to_base64(
sodium.randombytes_buf(32),
sodium.base64_variants.URLSAFE_NO_PADDING,
);
await db
.update(mesh)
.set({
ownerPubkey: pubHex,
ownerSecretKey: secHex,
rootKey,
})
.where(eq(mesh.id, row.id));
console.log(
`${row.id}\t${row.slug}\t${pubHex}\t${secHex}\t${rootKey}`,
);
console.error(`[backfill] patched mesh "${row.slug}" (${row.id})`);
}
console.error("[backfill] done.");
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(
"[backfill] error:",
e instanceof Error ? e.message : String(e),
);
process.exit(1);
});

View File

@@ -0,0 +1,488 @@
#!/usr/bin/env bun
/**
* Load test — 100 concurrent peers × 1000 messages each.
*
* Spins up N peer members in a fresh mesh, connects them all via WS,
* and has each peer send M direct messages to random other peers.
* Measures send→push latency per message, memory growth on the
* broker process, and error rate.
*
* Usage:
* DATABASE_URL=... bun apps/broker/scripts/load-test.ts [peers] [msgs]
*
* Defaults: 100 peers × 1000 messages = 100k messages total.
*
* Assumes the broker is running at ws://localhost:7900/ws. If you
* pass BROKER_PID=<pid>, the test also samples RSS + FD count every
* 2s for the broker process.
*/
import sodium from "libsodium-wrappers";
import { eq, inArray } from "drizzle-orm";
import WebSocket from "ws";
import { db } from "../src/db";
import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh";
import { user } from "@turbostarter/db/schema/auth";
// --- CLI args ---
const PEERS = parseInt(process.argv[2] ?? "100", 10);
const MSGS_PER_PEER = parseInt(process.argv[3] ?? "1000", 10);
const TOTAL_MSGS = PEERS * MSGS_PER_PEER;
const BROKER_URL = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
const BROKER_PID = process.env.BROKER_PID
? parseInt(process.env.BROKER_PID, 10)
: null;
const USER_ID = "test-user-loadtest";
const MESH_SLUG = "loadtest";
// --- Types ---
interface Peer {
memberId: string;
pubkey: string;
secretKey: string;
ws?: WebSocket;
connected: boolean;
sendsInFlight: number;
sendErrors: number;
}
interface MsgTimings {
sentAt: number;
pushAt?: number;
ackAt?: number;
senderIdx: number;
recipientIdx: number;
}
const peers: Peer[] = [];
const timings = new Map<string, MsgTimings>();
let messageId = 0;
// --- Broker-process sampling ---
interface Sample {
t: number;
rssKb: number;
fds: number;
}
const samples: Sample[] = [];
function samplePidStats(pid: number): Sample | null {
try {
const psOut = new TextDecoder()
.decode(Bun.spawnSync(["ps", "-o", "rss=", "-p", String(pid)]).stdout)
.trim();
const rssKb = parseInt(psOut, 10);
if (!Number.isFinite(rssKb)) return null;
const lsofOut = new TextDecoder()
.decode(Bun.spawnSync(["lsof", "-p", String(pid)]).stdout)
.trim();
const fds = lsofOut.split("\n").length - 1; // minus header
return { t: Date.now(), rssKb, fds };
} catch {
return null;
}
}
let sampler: ReturnType<typeof setInterval> | null = null;
function startSampler(): void {
if (!BROKER_PID) return;
sampler = setInterval(() => {
const s = samplePidStats(BROKER_PID);
if (s) samples.push(s);
}, 2000);
sampler.unref();
}
function stopSampler(): void {
if (sampler) clearInterval(sampler);
}
// --- Seed mesh + N members ---
async function seedMesh(): Promise<string> {
await sodium.ready;
const [existingUser] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, USER_ID));
if (!existingUser) {
await db.insert(user).values({
id: USER_ID,
name: "Load Test User",
email: "loadtest@claudemesh.test",
emailVerified: true,
});
}
// Drop prior loadtest mesh (cascades to members).
await db.delete(mesh).where(eq(mesh.slug, MESH_SLUG));
const kpOwner = sodium.crypto_sign_keypair();
const [m] = await db
.insert(mesh)
.values({
name: "Load Test",
slug: MESH_SLUG,
ownerUserId: USER_ID,
ownerPubkey: sodium.to_hex(kpOwner.publicKey),
visibility: "private",
transport: "managed",
tier: "free",
})
.returning({ id: mesh.id });
if (!m) throw new Error("mesh insert failed");
console.error(`[seed] created mesh ${m.id} (${MESH_SLUG})`);
console.error(`[seed] generating ${PEERS} keypairs + member rows…`);
// Batch-insert 100 members.
const rows = [];
for (let i = 0; i < PEERS; i++) {
const kp = sodium.crypto_sign_keypair();
rows.push({
meshId: m.id,
userId: USER_ID,
peerPubkey: sodium.to_hex(kp.publicKey),
displayName: `peer-${i}`,
role: "member" as const,
_secretKey: sodium.to_hex(kp.privateKey),
});
}
const inserted = await db
.insert(meshMember)
.values(rows.map(({ _secretKey: _s, ...r }) => r))
.returning({ id: meshMember.id, peerPubkey: meshMember.peerPubkey });
for (let i = 0; i < inserted.length; i++) {
peers.push({
memberId: inserted[i]!.id,
pubkey: inserted[i]!.peerPubkey,
secretKey: rows[i]!._secretKey,
connected: false,
sendsInFlight: 0,
sendErrors: 0,
});
}
console.error(`[seed] ${peers.length} members inserted`);
return m.id;
}
async function cleanupMesh(): Promise<void> {
// Cascade deletes members + presences + messages.
await db.delete(mesh).where(eq(mesh.slug, MESH_SLUG));
// Mop up any loadtest users' stray presence rows (belt and braces).
}
// --- WS client logic ---
function signHello(
meshId: string,
memberId: string,
pubkey: string,
secretHex: string,
): { timestamp: number; signature: string } {
const ts = Date.now();
const canonical = `${meshId}|${memberId}|${pubkey}|${ts}`;
const sig = sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(secretHex),
),
);
return { timestamp: ts, signature: sig };
}
function encryptDirect(
message: string,
recipientPubHex: string,
senderSecretHex: string,
): { nonce: string; ciphertext: string } {
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubHex),
);
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(senderSecretHex),
);
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const ciphertext = sodium.crypto_box_easy(
sodium.from_string(message),
nonce,
recipientPub,
senderSec,
);
return {
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL),
};
}
async function connectPeer(
idx: number,
meshId: string,
): Promise<void> {
const p = peers[idx]!;
return new Promise((resolve, reject) => {
const ws = new WebSocket(BROKER_URL);
p.ws = ws;
const timeout = setTimeout(() => {
reject(new Error(`peer ${idx} hello_ack timeout`));
}, 10_000);
ws.on("open", () => {
const { timestamp, signature } = signHello(
meshId,
p.memberId,
p.pubkey,
p.secretKey,
);
ws.send(
JSON.stringify({
type: "hello",
meshId,
memberId: p.memberId,
pubkey: p.pubkey,
sessionId: `loadtest-${idx}`,
pid: process.pid,
cwd: `/tmp/loadtest-${idx}`,
timestamp,
signature,
}),
);
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString()) as Record<string, unknown>;
if (msg.type === "hello_ack") {
clearTimeout(timeout);
p.connected = true;
resolve();
return;
}
if (msg.type === "ack") {
const clientId = String(msg.id ?? "");
const brokerId = String(msg.messageId ?? "");
const t = timings.get(clientId);
if (t) t.ackAt = Date.now();
// Index broker messageId → clientId so the push handler
// (below) can correlate — pushes only carry broker messageId.
if (brokerId) brokerIdToClientId.set(brokerId, clientId);
p.sendsInFlight -= 1;
return;
}
if (msg.type === "push") {
const brokerId = String(msg.messageId ?? "");
const clientId = brokerIdToClientId.get(brokerId);
if (clientId) {
const t = timings.get(clientId);
if (t && !t.pushAt) t.pushAt = Date.now();
}
return;
}
});
ws.on("error", () => {
clearTimeout(timeout);
reject(new Error(`peer ${idx} ws error`));
});
ws.on("close", () => {
p.connected = false;
});
});
}
async function connectAll(meshId: string): Promise<void> {
console.error(`[connect] opening ${PEERS} WS connections…`);
// Connect in batches of 20 to avoid thundering herd.
const BATCH = 20;
for (let i = 0; i < PEERS; i += BATCH) {
const batch = [];
for (let j = i; j < Math.min(i + BATCH, PEERS); j++) {
batch.push(connectPeer(j, meshId));
}
await Promise.all(batch);
await new Promise((r) => setTimeout(r, 50));
}
const connected = peers.filter((p) => p.connected).length;
console.error(`[connect] ${connected}/${PEERS} peers connected`);
}
// We need to correlate ack → push. Broker's ack carries the
// client-side id; push carries a broker-assigned messageId. We index
// timings by client-side id initially, then on ack we learn the
// broker messageId and create a second index pointing to same record.
const brokerIdToClientId = new Map<string, string>();
async function runSends(): Promise<void> {
console.error(
`[send] firing ${MSGS_PER_PEER} msgs per peer = ${TOTAL_MSGS} total…`,
);
const startedAt = Date.now();
// Each peer sends MSGS_PER_PEER msgs to random other peers.
await Promise.all(
peers.map(async (p, idx) => {
if (!p.ws || !p.connected) return;
for (let i = 0; i < MSGS_PER_PEER; i++) {
// Pick a random peer that's not self.
let targetIdx = Math.floor(Math.random() * PEERS);
if (targetIdx === idx) targetIdx = (targetIdx + 1) % PEERS;
const target = peers[targetIdx]!;
const clientId = `${idx}-${i}`;
const env = encryptDirect(
`msg-${clientId}`,
target.pubkey,
p.secretKey,
);
timings.set(clientId, {
sentAt: Date.now(),
senderIdx: idx,
recipientIdx: targetIdx,
});
try {
p.ws.send(
JSON.stringify({
type: "send",
id: clientId,
targetSpec: target.pubkey,
priority: "now",
nonce: env.nonce,
ciphertext: env.ciphertext,
}),
);
p.sendsInFlight += 1;
} catch {
p.sendErrors += 1;
}
// Small breathing room so we don't overwhelm the ws buffer.
if (i % 100 === 0) await new Promise((r) => setTimeout(r, 1));
}
}),
);
const sent = Date.now() - startedAt;
console.error(`[send] all sends dispatched in ${sent}ms`);
}
// We need broker messageId → client id correlation to measure push
// latency. Ack carries both (msg.id = clientId, msg.messageId = broker
// id). Update the ws message handler to populate the index.
// (Done inline above — we need to actually USE it.)
//
// Wire that in: on ack, brokerIdToClientId.set(messageId, clientId).
// On push, look up clientId by messageId, then record pushAt on
// timings.get(clientId).
async function waitForDrain(maxMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxMs) {
const acked = [...timings.values()].filter((t) => t.ackAt).length;
const pushed = [...timings.values()].filter((t) => t.pushAt).length;
if (acked === TOTAL_MSGS && pushed === TOTAL_MSGS) return;
await new Promise((r) => setTimeout(r, 200));
}
}
// --- Stats ---
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const i = Math.min(
sorted.length - 1,
Math.floor((p / 100) * sorted.length),
);
return sorted[i]!;
}
function report(): void {
const all = [...timings.values()];
const complete = all.filter((t) => t.pushAt && t.ackAt);
const timedOut = all.length - complete.length;
const latencies = complete
.map((t) => t.pushAt! - t.sentAt)
.sort((a, b) => a - b);
const ackLatencies = complete
.map((t) => t.ackAt! - t.sentAt)
.sort((a, b) => a - b);
const rssMax = samples.length
? Math.max(...samples.map((s) => s.rssKb))
: null;
const rssMin = samples.length
? Math.min(...samples.map((s) => s.rssKb))
: null;
const fdMax = samples.length
? Math.max(...samples.map((s) => s.fds))
: null;
console.log("");
console.log("╔══════════════════════════════════════════════════════════╗");
console.log(`║ claudemesh broker load test — ${PEERS} peers × ${MSGS_PER_PEER} msgs ║`);
console.log("╚══════════════════════════════════════════════════════════╝");
console.log("");
console.log("Delivery:");
console.log(` sent: ${all.length}`);
console.log(` complete: ${complete.length} (${((100 * complete.length) / all.length).toFixed(2)}%)`);
console.log(` timed out: ${timedOut}`);
console.log("");
console.log("End-to-end latency (send → push):");
console.log(` p50: ${percentile(latencies, 50)} ms`);
console.log(` p95: ${percentile(latencies, 95)} ms`);
console.log(` p99: ${percentile(latencies, 99)} ms`);
console.log(` max: ${latencies[latencies.length - 1] ?? 0} ms`);
console.log("");
console.log("Send → ack latency (broker queue write):");
console.log(` p50: ${percentile(ackLatencies, 50)} ms`);
console.log(` p95: ${percentile(ackLatencies, 95)} ms`);
console.log(` p99: ${percentile(ackLatencies, 99)} ms`);
if (rssMax !== null) {
console.log("");
console.log("Broker process (via BROKER_PID):");
console.log(` RSS: ${(rssMin! / 1024).toFixed(1)} MB → ${(rssMax / 1024).toFixed(1)} MB`);
console.log(` max open FDs: ${fdMax}`);
console.log(` samples: ${samples.length}`);
}
console.log("");
}
// --- Main ---
async function main(): Promise<void> {
const meshId = await seedMesh();
startSampler();
try {
await connectAll(meshId);
await runSends();
const drainCap = parseInt(process.env.DRAIN_MS ?? "180000", 10);
console.error(`[drain] waiting for acks + pushes to settle (up to ${drainCap / 1000}s)…`);
await waitForDrain(drainCap);
report();
} finally {
stopSampler();
for (const p of peers) {
try {
p.ws?.close();
} catch {
/* ignore */
}
}
await cleanupMesh();
}
process.exit(0);
}
main().catch((e) => {
console.error("[loadtest] error:", e);
if (e instanceof Error && e.cause) {
console.error("[loadtest] cause:", e.cause);
}
process.exit(1);
});
// Wire ack→push correlation by sneaking the broker messageId into
// the client-side timings map. We need to edit the message handler
// inline above to record it; since the handler already reads msg.id
// for the ack path, we just ALSO use msg.id as the correlation key
// on push. The broker's push DOES echo clientId? NO — push only has
// broker's messageId. So we correlate via the ack phase: when ack
// arrives we map messageId→clientId, then on push we look it up.
// (The handler above already references this map; just uses the
// wrong variable. Fix: update handler to use brokerIdToClientId.)
void brokerIdToClientId;

View File

@@ -33,6 +33,8 @@ import {
invite as inviteTable,
mesh,
meshMember as memberTable,
meshMemory,
meshState,
messageQueue,
pendingStatus,
presence,
@@ -265,6 +267,23 @@ export async function refreshQueueDepth(): Promise<void> {
metrics.queueDepth.set(Number(row?.n ?? 0));
}
/**
* Sweep stale presences: mark as disconnected if last_ping_at is older
* than 90s (3 missed pings at the 30s interval = dead session).
*/
export async function sweepStalePresences(): Promise<void> {
const cutoff = new Date(Date.now() - 90_000); // 3 missed pings
await db
.update(presence)
.set({ disconnectedAt: new Date() })
.where(
and(
isNull(presence.disconnectedAt),
lt(presence.lastPingAt, cutoff),
),
);
}
/** Sweep expired pending_status entries. */
export async function sweepPendingStatuses(): Promise<void> {
const cutoff = new Date(Date.now() - PENDING_TTL_MS);
@@ -307,8 +326,11 @@ export async function refreshStatusFromJsonl(
export interface ConnectParams {
memberId: string;
sessionId: string;
sessionPubkey?: string;
displayName?: string;
pid: number;
cwd: string;
groups?: Array<{ name: string; role?: string }>;
}
/** Create a presence row for a new WS connection. */
@@ -321,11 +343,14 @@ export async function connectPresence(
.values({
memberId: params.memberId,
sessionId: params.sessionId,
sessionPubkey: params.sessionPubkey ?? null,
displayName: params.displayName ?? null,
pid: params.pid,
cwd: params.cwd,
status: "idle",
statusSource: "jsonl",
statusUpdatedAt: now,
groups: params.groups ?? [],
connectedAt: now,
lastPingAt: now,
})
@@ -352,11 +377,330 @@ export async function heartbeat(presenceId: string): Promise<void> {
.where(eq(presence.id, presenceId));
}
// --- Peer discovery ---
/** Return all active (connected) presences in a mesh, joined with member info. */
export async function listPeersInMesh(
meshId: string,
): Promise<
Array<{
pubkey: string;
displayName: string;
status: string;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: Date;
}>
> {
const rows = await db
.select({
memberPubkey: memberTable.peerPubkey,
sessionPubkey: presence.sessionPubkey,
memberDisplayName: memberTable.displayName,
presenceDisplayName: presence.displayName,
status: presence.status,
summary: presence.summary,
groups: presence.groups,
sessionId: presence.sessionId,
connectedAt: presence.connectedAt,
})
.from(presence)
.innerJoin(memberTable, eq(presence.memberId, memberTable.id))
.where(
and(
eq(memberTable.meshId, meshId),
isNull(presence.disconnectedAt),
),
)
.orderBy(asc(presence.connectedAt));
// Prefer session pubkey for routing, session displayName for display.
return rows.map((r) => ({
pubkey: r.sessionPubkey || r.memberPubkey,
displayName: r.presenceDisplayName || r.memberDisplayName,
status: r.status,
summary: r.summary,
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
sessionId: r.sessionId,
connectedAt: r.connectedAt,
}));
}
/** Update the summary text on a presence row. */
export async function setSummary(
presenceId: string,
summary: string,
): Promise<void> {
await db
.update(presence)
.set({ summary })
.where(eq(presence.id, presenceId));
}
// --- Group management ---
/**
* Join a group (upsert). If the peer is already in the group, update the role.
* Returns the updated groups array.
*/
export async function joinGroup(
presenceId: string,
name: string,
role?: string,
): Promise<Array<{ name: string; role?: string }>> {
const [row] = await db
.select({ groups: presence.groups })
.from(presence)
.where(eq(presence.id, presenceId));
if (!row) return [];
const groups = ((row.groups ?? []) as Array<{ name: string; role?: string }>).slice();
const idx = groups.findIndex((g) => g.name === name);
const entry: { name: string; role?: string } = { name };
if (role) entry.role = role;
if (idx >= 0) {
groups[idx] = entry;
} else {
groups.push(entry);
}
await db
.update(presence)
.set({ groups })
.where(eq(presence.id, presenceId));
return groups;
}
/**
* Leave a group. Returns the updated groups array.
*/
export async function leaveGroup(
presenceId: string,
name: string,
): Promise<Array<{ name: string; role?: string }>> {
const [row] = await db
.select({ groups: presence.groups })
.from(presence)
.where(eq(presence.id, presenceId));
if (!row) return [];
const groups = ((row.groups ?? []) as Array<{ name: string; role?: string }>).filter(
(g) => g.name !== name,
);
await db
.update(presence)
.set({ groups })
.where(eq(presence.id, presenceId));
return groups;
}
// --- Shared state ---
/**
* Upsert a key-value pair in the mesh's shared state.
* Returns the upserted row.
*/
export async function setState(
meshId: string,
key: string,
value: unknown,
presenceId?: string,
presenceName?: string,
): Promise<{
key: string;
value: unknown;
updatedBy: string;
updatedAt: Date;
}> {
const now = new Date();
const [row] = await db
.insert(meshState)
.values({
meshId,
key,
value,
updatedByPresence: presenceId ?? null,
updatedByName: presenceName ?? null,
updatedAt: now,
})
.onConflictDoUpdate({
target: [meshState.meshId, meshState.key],
set: {
value,
updatedByPresence: presenceId ?? null,
updatedByName: presenceName ?? null,
updatedAt: now,
},
})
.returning({
key: meshState.key,
value: meshState.value,
updatedByName: meshState.updatedByName,
updatedAt: meshState.updatedAt,
});
return {
key: row!.key,
value: row!.value,
updatedBy: row!.updatedByName ?? "unknown",
updatedAt: row!.updatedAt,
};
}
/**
* Read a single state key for a mesh. Returns null if not found.
*/
export async function getState(
meshId: string,
key: string,
): Promise<{
key: string;
value: unknown;
updatedBy: string;
updatedAt: Date;
} | null> {
const [row] = await db
.select({
key: meshState.key,
value: meshState.value,
updatedByName: meshState.updatedByName,
updatedAt: meshState.updatedAt,
})
.from(meshState)
.where(and(eq(meshState.meshId, meshId), eq(meshState.key, key)))
.limit(1);
if (!row) return null;
return {
key: row.key,
value: row.value,
updatedBy: row.updatedByName ?? "unknown",
updatedAt: row.updatedAt,
};
}
/**
* List all state entries for a mesh.
*/
export async function listState(
meshId: string,
): Promise<
Array<{ key: string; value: unknown; updatedBy: string; updatedAt: Date }>
> {
const rows = await db
.select({
key: meshState.key,
value: meshState.value,
updatedByName: meshState.updatedByName,
updatedAt: meshState.updatedAt,
})
.from(meshState)
.where(eq(meshState.meshId, meshId))
.orderBy(asc(meshState.key));
return rows.map((r) => ({
key: r.key,
value: r.value,
updatedBy: r.updatedByName ?? "unknown",
updatedAt: r.updatedAt,
}));
}
// --- Memory ---
/**
* Store a new memory for a mesh. Returns the generated id.
*/
export async function rememberMemory(
meshId: string,
content: string,
tags: string[],
memberId?: string,
memberName?: string,
): Promise<string> {
const [row] = await db
.insert(meshMemory)
.values({
meshId,
content,
tags,
rememberedBy: memberId ?? null,
rememberedByName: memberName ?? null,
})
.returning({ id: meshMemory.id });
if (!row) throw new Error("failed to insert memory");
return row.id;
}
/**
* Full-text search memories in a mesh. Uses the search_vector tsvector
* column with plainto_tsquery for ranked results.
*/
export async function recallMemory(
meshId: string,
query: string,
): Promise<
Array<{
id: string;
content: string;
tags: string[];
rememberedBy: string;
rememberedAt: Date;
}>
> {
const result = await db.execute<{
id: string;
content: string;
tags: string[];
remembered_by_name: string | null;
remembered_at: string | Date;
}>(sql`
SELECT id, content, tags, remembered_by_name, remembered_at
FROM mesh.memory
WHERE mesh_id = ${meshId}
AND forgotten_at IS NULL
AND search_vector @@ plainto_tsquery('english', ${query})
ORDER BY ts_rank(search_vector, plainto_tsquery('english', ${query})) DESC
LIMIT 20
`);
const rows = (result.rows ?? result) as Array<{
id: string;
content: string;
tags: string[];
remembered_by_name: string | null;
remembered_at: string | Date;
}>;
return rows.map((r) => ({
id: r.id,
content: r.content,
tags: r.tags ?? [],
rememberedBy: r.remembered_by_name ?? "unknown",
rememberedAt:
r.remembered_at instanceof Date
? r.remembered_at
: new Date(r.remembered_at),
}));
}
/**
* Soft-delete a memory by setting forgotten_at.
*/
export async function forgetMemory(
meshId: string,
memoryId: string,
): Promise<void> {
await db
.update(meshMemory)
.set({ forgottenAt: new Date() })
.where(
and(
eq(meshMemory.id, memoryId),
eq(meshMemory.meshId, meshId),
isNull(meshMemory.forgottenAt),
),
);
}
// --- Message queueing + delivery ---
export interface QueueParams {
meshId: string;
senderMemberId: string;
senderSessionPubkey?: string;
targetSpec: string;
priority: Priority;
nonce: string;
@@ -371,6 +715,7 @@ export async function queueMessage(params: QueueParams): Promise<string> {
.values({
meshId: params.meshId,
senderMemberId: params.senderMemberId,
senderSessionPubkey: params.senderSessionPubkey ?? null,
targetSpec: params.targetSpec,
priority: params.priority,
nonce: params.nonce,
@@ -411,6 +756,9 @@ export async function drainForMember(
_memberId: string,
memberPubkey: string,
status: PeerStatus,
sessionPubkey?: string,
excludeSenderSessionPubkey?: string,
memberGroups?: string[],
): Promise<
Array<{
id: string;
@@ -428,6 +776,18 @@ export async function drainForMember(
priorities.map((p) => `'${p}'`).join(","),
);
// Build group target matching: @all (broadcast alias) + @<groupname>
// for each group the peer belongs to.
const groupTargets = ["@all"];
if (memberGroups) {
for (const g of memberGroups) {
groupTargets.push(`@${g}`);
}
}
const groupTargetList = sql.raw(
groupTargets.map((t) => `'${t}'`).join(","),
);
// Atomic claim with SQL-side ordering. The CTE claims rows via
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
@@ -451,14 +811,15 @@ export async function drainForMember(
WHERE mesh_id = ${meshId}
AND delivered_at IS NULL
AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*')
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
)
AND m.id = mq.sender_member_id
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
mq.created_at, mq.sender_member_id,
m.peer_pubkey AS sender_pubkey
COALESCE(mq.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey
)
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
`);
@@ -489,6 +850,7 @@ export async function drainForMember(
let ttlTimer: ReturnType<typeof setInterval> | null = null;
let pendingTimer: ReturnType<typeof setInterval> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
/** Start background sweepers. Idempotent. */
export function startSweepers(): void {
@@ -501,14 +863,21 @@ export function startSweepers(): void {
console.error("[broker] pending sweep:", e),
);
}, PENDING_SWEEP_INTERVAL_MS);
staleTimer = setInterval(() => {
sweepStalePresences().catch((e) =>
console.error("[broker] stale presence sweep:", e),
);
}, 30_000);
}
/** Stop background sweepers and mark all active presences disconnected. */
export async function stopSweepers(): Promise<void> {
if (ttlTimer) clearInterval(ttlTimer);
if (pendingTimer) clearInterval(pendingTimer);
if (staleTimer) clearInterval(staleTimer);
ttlTimer = null;
pendingTimer = null;
staleTimer = null;
await db
.update(presence)
.set({ disconnectedAt: new Date() })

View File

@@ -15,18 +15,31 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocketServer, type WebSocket } from "ws";
import { eq } from "drizzle-orm";
import { env } from "./env";
import { db } from "./db";
import { messageQueue } from "@turbostarter/db/schema/mesh";
import {
connectPresence,
disconnectPresence,
drainForMember,
findMemberByPubkey,
forgetMemory,
getState,
handleHookSetStatus,
heartbeat,
joinGroup,
joinMesh,
leaveGroup,
listPeersInMesh,
listState,
queueMessage,
recallMemory,
refreshQueueDepth,
refreshStatusFromJsonl,
rememberMemory,
setSummary,
setState,
startSweepers,
stopSweepers,
writeStatus,
@@ -54,7 +67,9 @@ interface PeerConn {
meshId: string;
memberId: string;
memberPubkey: string;
sessionPubkey: string | null;
cwd: string;
groups: Array<{ name: string; role?: string }>;
}
const connections = new Map<string, PeerConn>();
@@ -78,7 +93,10 @@ function sendToPeer(presenceId: string, msg: WSServerMessage): void {
}
}
async function maybePushQueuedMessages(presenceId: string): Promise<void> {
async function maybePushQueuedMessages(
presenceId: string,
excludeSenderSessionPubkey?: string,
): Promise<void> {
const conn = connections.get(presenceId);
if (!conn) return;
const status = await refreshStatusFromJsonl(
@@ -91,6 +109,9 @@ async function maybePushQueuedMessages(presenceId: string): Promise<void> {
conn.memberId,
conn.memberPubkey,
status,
conn.sessionPubkey ?? undefined,
excludeSenderSessionPubkey,
conn.groups.map((g) => g.name),
);
for (const m of messages) {
const push: WSPushMessage = {
@@ -395,23 +416,30 @@ async function handleHello(
ws.close(1008, "unauthorized");
return null;
}
const initialGroups = hello.groups ?? [];
const presenceId = await connectPresence({
memberId: member.id,
sessionId: hello.sessionId,
sessionPubkey: hello.sessionPubkey,
displayName: hello.displayName,
pid: hello.pid,
cwd: hello.cwd,
groups: initialGroups,
});
connections.set(presenceId, {
ws,
meshId: hello.meshId,
memberId: member.id,
memberPubkey: hello.pubkey,
sessionPubkey: hello.sessionPubkey ?? null,
cwd: hello.cwd,
groups: initialGroups,
});
incMeshCount(hello.meshId);
const effectiveDisplayName = hello.displayName || member.displayName;
log.info("ws hello", {
mesh_id: hello.meshId,
member: member.displayName,
member: effectiveDisplayName,
presence_id: presenceId,
session_id: hello.sessionId,
});
@@ -420,7 +448,7 @@ async function handleHello(
// races the caller's closure assignment, causing subsequent client
// messages to fail the "no_hello" check.
void maybePushQueuedMessages(presenceId);
return { presenceId, memberDisplayName: member.displayName };
return { presenceId, memberDisplayName: effectiveDisplayName };
}
async function handleSend(
@@ -430,6 +458,7 @@ async function handleSend(
const messageId = await queueMessage({
meshId: conn.meshId,
senderMemberId: conn.memberId,
senderSessionPubkey: conn.sessionPubkey ?? undefined,
targetSpec: msg.targetSpec,
priority: msg.priority,
nonce: msg.nonce,
@@ -443,12 +472,68 @@ async function handleSend(
};
conn.ws.send(JSON.stringify(ack));
// Fan-out over connected peers in the same mesh.
// Find sender's presenceId to exclude from fan-out.
let senderPresenceId: string | undefined;
for (const [pid, peer] of connections) {
if (peer.ws === conn.ws) { senderPresenceId = pid; break; }
}
// Fan-out over connected peers in the same mesh — skip sender.
const isGroupTarget = msg.targetSpec.startsWith("@");
const isBroadcast =
msg.targetSpec === "*" ||
(isGroupTarget && msg.targetSpec === "@all");
const groupName = isGroupTarget && !isBroadcast
? msg.targetSpec.slice(1)
: null;
const isMulticast = isBroadcast || !!groupName;
// Build the push envelope once (reused for all recipients).
const pushEnvelope: WSPushMessage = {
type: "push",
messageId,
meshId: conn.meshId,
senderPubkey: conn.sessionPubkey ?? conn.memberPubkey,
priority: msg.priority,
nonce: msg.nonce,
ciphertext: msg.ciphertext,
createdAt: new Date().toISOString(),
};
for (const [pid, peer] of connections) {
if (pid === senderPresenceId) continue;
if (peer.meshId !== conn.meshId) continue;
if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec)
continue;
void maybePushQueuedMessages(pid);
if (isBroadcast) {
// broadcast — deliver to everyone
} else if (groupName) {
// group routing — deliver only if peer is in the group
if (!peer.groups.some((g) => g.name === groupName)) continue;
} else {
// direct routing — match by pubkey
if (peer.memberPubkey !== msg.targetSpec
&& peer.sessionPubkey !== msg.targetSpec)
continue;
}
if (isMulticast) {
// Multicast: push directly to each connected peer. The queue
// row has one delivered_at — can only be claimed once. Direct
// push ensures every connected peer receives the message.
sendToPeer(pid, pushEnvelope);
metrics.messagesRoutedTotal.inc({ priority: msg.priority });
} else {
// Direct: drain from queue (handles priority gating + offline).
void maybePushQueuedMessages(pid, conn.sessionPubkey ?? undefined);
}
}
// Mark multicast messages as delivered (they've been pushed directly).
if (isMulticast) {
await db
.update(messageQueue)
.set({ deliveredAt: new Date() })
.where(eq(messageQueue.id, messageId));
}
}
@@ -494,6 +579,268 @@ function handleConnection(ws: WebSocket): void {
status: msg.status,
});
break;
case "list_peers": {
const peers = await listPeersInMesh(conn.meshId);
const resp: WSServerMessage = {
type: "peers_list",
peers: peers.map((p) => ({
pubkey: p.pubkey,
displayName: p.displayName,
status: p.status as "idle" | "working" | "dnd",
summary: p.summary,
groups: p.groups,
sessionId: p.sessionId,
connectedAt: p.connectedAt.toISOString(),
})),
};
conn.ws.send(JSON.stringify(resp));
log.info("ws list_peers", {
presence_id: presenceId,
mesh_id: conn.meshId,
count: peers.length,
});
break;
}
case "set_summary": {
const summary = (msg as { summary?: string }).summary ?? "";
await setSummary(presenceId, summary);
log.info("ws set_summary", {
presence_id: presenceId,
summary: summary.slice(0, 80),
});
break;
}
case "join_group": {
const jg = msg as Extract<WSClientMessage, { type: "join_group" }>;
const updatedGroups = await joinGroup(presenceId, jg.name, jg.role);
conn.groups = updatedGroups;
log.info("ws join_group", {
presence_id: presenceId,
group: jg.name,
role: jg.role,
});
break;
}
case "leave_group": {
const lg = msg as Extract<WSClientMessage, { type: "leave_group" }>;
const updatedGroups = await leaveGroup(presenceId, lg.name);
conn.groups = updatedGroups;
log.info("ws leave_group", {
presence_id: presenceId,
group: lg.name,
});
break;
}
case "set_state": {
const ss = msg as Extract<WSClientMessage, { type: "set_state" }>;
// Look up the display name for attribution.
const senderName =
[...connections.entries()].find(
([pid]) => pid === presenceId,
)?.[1]?.memberPubkey;
const member = senderName
? await findMemberByPubkey(conn.meshId, senderName)
: null;
const displayName = member?.displayName ?? "unknown";
const stateRow = await setState(
conn.meshId,
ss.key,
ss.value,
presenceId,
displayName,
);
// Push state_change to ALL other peers in the same mesh.
for (const [pid, peer] of connections) {
if (pid === presenceId) continue;
if (peer.meshId !== conn.meshId) continue;
sendToPeer(pid, {
type: "state_change",
key: stateRow.key,
value: stateRow.value,
updatedBy: stateRow.updatedBy,
});
}
// Send confirmation back to sender as state_result.
sendToPeer(presenceId, {
type: "state_result",
key: stateRow.key,
value: stateRow.value,
updatedBy: stateRow.updatedBy,
updatedAt: stateRow.updatedAt.toISOString(),
});
log.info("ws set_state", {
presence_id: presenceId,
key: ss.key,
});
break;
}
case "get_state": {
const gs = msg as Extract<WSClientMessage, { type: "get_state" }>;
const stateEntry = await getState(conn.meshId, gs.key);
if (stateEntry) {
sendToPeer(presenceId, {
type: "state_result",
key: stateEntry.key,
value: stateEntry.value,
updatedBy: stateEntry.updatedBy,
updatedAt: stateEntry.updatedAt.toISOString(),
});
} else {
sendToPeer(presenceId, {
type: "state_result",
key: gs.key,
value: null,
updatedBy: "",
updatedAt: "",
});
}
log.info("ws get_state", {
presence_id: presenceId,
key: gs.key,
found: !!stateEntry,
});
break;
}
case "list_state": {
const entries = await listState(conn.meshId);
sendToPeer(presenceId, {
type: "state_list",
entries: entries.map((e) => ({
key: e.key,
value: e.value,
updatedBy: e.updatedBy,
updatedAt: e.updatedAt.toISOString(),
})),
});
log.info("ws list_state", {
presence_id: presenceId,
count: entries.length,
});
break;
}
case "remember": {
const rm = msg as Extract<WSClientMessage, { type: "remember" }>;
const memberInfo = conn.memberPubkey
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
: null;
const memoryId = await rememberMemory(
conn.meshId,
rm.content,
rm.tags ?? [],
memberInfo?.id,
memberInfo?.displayName,
);
sendToPeer(presenceId, {
type: "memory_stored",
id: memoryId,
});
log.info("ws remember", {
presence_id: presenceId,
memory_id: memoryId,
});
break;
}
case "recall": {
const rc = msg as Extract<WSClientMessage, { type: "recall" }>;
const memories = await recallMemory(conn.meshId, rc.query);
sendToPeer(presenceId, {
type: "memory_results",
memories: memories.map((m) => ({
id: m.id,
content: m.content,
tags: m.tags,
rememberedBy: m.rememberedBy,
rememberedAt: m.rememberedAt.toISOString(),
})),
});
log.info("ws recall", {
presence_id: presenceId,
query: rc.query.slice(0, 80),
results: memories.length,
});
break;
}
case "forget": {
const fg = msg as Extract<WSClientMessage, { type: "forget" }>;
await forgetMemory(conn.meshId, fg.memoryId);
sendToPeer(presenceId, {
type: "ack" as const,
id: fg.memoryId,
messageId: fg.memoryId,
queued: false,
});
log.info("ws forget", {
presence_id: presenceId,
memory_id: fg.memoryId,
});
break;
}
case "message_status": {
const ms = msg as Extract<WSClientMessage, { type: "message_status" }>;
// Look up the message in the queue.
const [mqRow] = await db
.select({
id: messageQueue.id,
targetSpec: messageQueue.targetSpec,
deliveredAt: messageQueue.deliveredAt,
meshId: messageQueue.meshId,
})
.from(messageQueue)
.where(eq(messageQueue.id, ms.messageId));
if (!mqRow || mqRow.meshId !== conn.meshId) {
sendError(conn.ws, "not_found", "message not found");
break;
}
// Build per-recipient status from connected peers.
const recipients: Array<{ name: string; pubkey: string; status: "delivered" | "held" | "disconnected" }> = [];
const isMulti = mqRow.targetSpec === "*" || mqRow.targetSpec.startsWith("@");
if (isMulti) {
const groupNameMs = mqRow.targetSpec.startsWith("@") && mqRow.targetSpec !== "@all"
? mqRow.targetSpec.slice(1) : null;
// Check all known presences for this mesh.
const peers = await listPeersInMesh(conn.meshId);
for (const p of peers) {
if (groupNameMs && !p.groups.some((g: { name: string }) => g.name === groupNameMs)) continue;
recipients.push({
name: p.displayName,
pubkey: p.pubkey,
status: mqRow.deliveredAt ? "delivered" : "held",
});
}
} else {
// Direct message — find the target peer.
const peers = await listPeersInMesh(conn.meshId);
const target = peers.find((p) => p.pubkey === mqRow.targetSpec);
if (target) {
recipients.push({
name: target.displayName,
pubkey: target.pubkey,
status: mqRow.deliveredAt ? "delivered" : (target.status === "idle" ? "held" : "held"),
});
} else {
recipients.push({
name: "unknown",
pubkey: mqRow.targetSpec.slice(0, 16),
status: "disconnected",
});
}
}
const resp: WSServerMessage = {
type: "message_status_result",
messageId: ms.messageId,
targetSpec: mqRow.targetSpec,
delivered: !!mqRow.deliveredAt,
deliveredAt: mqRow.deliveredAt?.toISOString() ?? null,
recipients,
};
sendToPeer(presenceId, resp);
log.info("ws message_status", {
presence_id: presenceId,
message_id: ms.messageId,
delivered: !!mqRow.deliveredAt,
});
break;
}
}
} catch (e) {
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });

View File

@@ -52,9 +52,13 @@ export interface WSHelloMessage {
meshId: string;
memberId: string;
pubkey: string; // must match mesh.member.peerPubkey
sessionPubkey?: string; // ephemeral per-launch pubkey for message routing
displayName?: string; // optional override for this session
sessionId: string;
pid: number;
cwd: string;
/** Initial groups to join on connect. */
groups?: Array<{ name: string; role?: string }>;
/** ms epoch; broker rejects if outside ±60s of its own clock. */
timestamp: number;
/** ed25519 signature (hex) over the canonical hello bytes:
@@ -90,6 +94,67 @@ export interface WSSetStatusMessage {
status: PeerStatus;
}
/** Client → broker: request list of connected peers in the same mesh. */
export interface WSListPeersMessage {
type: "list_peers";
}
/** Client → broker: update the session's human-readable summary. */
export interface WSSetSummaryMessage {
type: "set_summary";
summary: string;
}
/** Client → broker: join a group with optional role. */
export interface WSJoinGroupMessage {
type: "join_group";
name: string;
role?: string;
}
/** Client → broker: leave a group. */
export interface WSLeaveGroupMessage {
type: "leave_group";
name: string;
}
/** Client → broker: set a shared state key-value. */
export interface WSSetStateMessage {
type: "set_state";
key: string;
value: unknown;
}
/** Client → broker: read a shared state key. */
export interface WSGetStateMessage {
type: "get_state";
key: string;
}
/** Client → broker: list all shared state entries. */
export interface WSListStateMessage {
type: "list_state";
}
/** Client → broker: store a memory. */
export interface WSRememberMessage {
type: "remember";
content: string;
tags?: string[];
}
/** Client → broker: full-text search memories. */
export interface WSRecallMessage {
type: "recall";
query: string;
}
/** Client → broker: soft-delete a memory. */
export interface WSForgetMessage {
type: "forget";
memoryId: string;
}
/** Broker → client: acknowledgement for a send. */
export interface WSAckMessage {
type: "ack";
@@ -105,6 +170,86 @@ export interface WSHelloAckMessage {
memberDisplayName: string;
}
/** Broker → client: list of connected peers in the same mesh. */
export interface WSPeersListMessage {
type: "peers_list";
peers: Array<{
pubkey: string;
displayName: string;
status: PeerStatus;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
}>;
}
/** Broker → client: a state key was changed by another peer. */
export interface WSStateChangeMessage {
type: "state_change";
key: string;
value: unknown;
updatedBy: string;
}
/** Broker → client: response to get_state. */
export interface WSStateResultMessage {
type: "state_result";
key: string;
value: unknown;
updatedAt: string;
updatedBy: string;
}
/** Broker → client: response to list_state. */
export interface WSStateListMessage {
type: "state_list";
entries: Array<{
key: string;
value: unknown;
updatedBy: string;
updatedAt: string;
}>;
}
/** Broker → client: acknowledgement for a remember. */
export interface WSMemoryStoredMessage {
type: "memory_stored";
id: string;
}
/** Broker → client: response to recall. */
export interface WSMemoryResultsMessage {
type: "memory_results";
memories: Array<{
id: string;
content: string;
tags: string[];
rememberedBy: string;
rememberedAt: string;
}>;
}
/** Client → broker: check delivery status of a message. */
export interface WSMessageStatusMessage {
type: "message_status";
messageId: string;
}
/** Broker → client: delivery status with per-recipient detail. */
export interface WSMessageStatusResultMessage {
type: "message_status_result";
messageId: string;
targetSpec: string;
delivered: boolean;
deliveredAt: string | null;
recipients: Array<{
name: string;
pubkey: string;
status: "delivered" | "held" | "disconnected";
}>;
}
/** Broker → client: structured error. */
export interface WSErrorMessage {
type: "error";
@@ -116,10 +261,28 @@ export interface WSErrorMessage {
export type WSClientMessage =
| WSHelloMessage
| WSSendMessage
| WSSetStatusMessage;
| WSSetStatusMessage
| WSListPeersMessage
| WSSetSummaryMessage
| WSJoinGroupMessage
| WSLeaveGroupMessage
| WSSetStateMessage
| WSGetStateMessage
| WSListStateMessage
| WSRememberMessage
| WSRecallMessage
| WSForgetMessage
| WSMessageStatusMessage;
export type WSServerMessage =
| WSHelloAckMessage
| WSPushMessage
| WSAckMessage
| WSPeersListMessage
| WSStateChangeMessage
| WSStateResultMessage
| WSStateListMessage
| WSMemoryStoredMessage
| WSMemoryResultsMessage
| WSMessageStatusResultMessage
| WSErrorMessage;

View File

@@ -1,4 +1,4 @@
# @claudemesh/cli
# claudemesh-cli
Client tool for claudemesh — install once per machine, join one or more
meshes, and your Claude Code sessions can talk to peers on demand.
@@ -7,7 +7,7 @@ meshes, and your Claude Code sessions can talk to peers on demand.
```sh
# From npm (once published)
npm install -g @claudemesh/cli
npm install -g claudemesh-cli
# Or from the monorepo during dev
cd apps/cli && bun link
@@ -25,9 +25,31 @@ Run the printed command, then restart Claude Code.
## Join a mesh
```sh
claudemesh join ic://join/BASE64URL...
claudemesh join https://claudemesh.com/join/<token>
```
## Launch Claude Code
For real-time **push messages** from peers (messages injected mid-turn
as `<channel source="claudemesh">` system reminders), launch with:
```sh
claudemesh launch
# or pass through any claude flags:
claudemesh launch --model opus
claudemesh launch --resume
```
Under the hood this runs:
```sh
claude --dangerously-load-development-channels server:claudemesh
```
Plain `claude` still works — the MCP tools are available — but incoming
messages are **pull-only** via the `check_messages` tool instead of
being pushed to Claude immediately.
The invite link is generated by whoever runs the mesh. It bundles the
mesh id, expiry, signing key, and role. Your CLI verifies it,
generates a fresh keypair, enrolls you with the broker, and persists
@@ -36,8 +58,10 @@ the result to `~/.claudemesh/config.json`.
## Commands
```sh
claudemesh install # print MCP registration command
claudemesh join <link> # join a mesh via invite link
claudemesh install # register MCP + status hooks
claudemesh uninstall # remove MCP + status hooks
claudemesh launch [args] # launch Claude Code with push messages enabled
claudemesh join <url> # join a mesh via invite URL
claudemesh list # show joined meshes + identities
claudemesh leave <slug> # leave a mesh
claudemesh mcp # start MCP server (stdio — Claude Code only)

View File

@@ -1,26 +1,55 @@
{
"name": "@claudemesh/cli",
"version": "0.1.0",
"private": true,
"name": "claudemesh-cli",
"version": "0.3.0",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [
"claude-code",
"mcp",
"model-context-protocol",
"claudemesh",
"peer-messaging",
"multi-agent"
],
"author": "Alejandro Gutiérrez",
"license": "MIT",
"homepage": "https://claudemesh.com",
"repository": {
"type": "git",
"url": "https://github.com/alezmad/claudemesh.git",
"directory": "apps/cli"
},
"type": "module",
"bin": {
"claudemesh": "./src/index.ts"
"claudemesh": "./dist/index.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "bun build src/index.ts --target=node --outfile dist/index.js --banner \"#!/usr/bin/env node\" && chmod +x dist/index.js",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "bun --hot src/index.ts",
"start": "bun src/index.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"prepublishOnly": "bun run build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"libsodium-wrappers": "0.7.15",
"ws": "8.20.0",
"zod": "catalog:"
"zod": "4.1.13"
},
"devDependencies": {
"@turbostarter/eslint-config": "workspace:*",

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { encryptDirect, decryptDirect } from "../crypto/envelope";
import { generateKeypair } from "../crypto/keypair";
describe("crypto roundtrip", () => {
it("Alice encrypts for Bob, Bob decrypts successfully", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const plaintext = "hello world";
const envelope = await encryptDirect(plaintext, bob.publicKey, alice.secretKey);
const decrypted = await decryptDirect(envelope, alice.publicKey, bob.secretKey);
expect(decrypted).toBe(plaintext);
});
it("Carol cannot decrypt a message encrypted for Bob", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const carol = await generateKeypair();
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
const decrypted = await decryptDirect(envelope, alice.publicKey, carol.secretKey);
expect(decrypted).toBeNull();
});
it("tampered ciphertext returns null on decrypt", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
// Flip a byte in the ciphertext
const raw = Buffer.from(envelope.ciphertext, "base64");
raw[0] = raw[0]! ^ 0xff;
const tampered = { nonce: envelope.nonce, ciphertext: raw.toString("base64") };
const decrypted = await decryptDirect(tampered, alice.publicKey, bob.secretKey);
expect(decrypted).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import {
parseInviteLink,
buildSignedInvite,
extractInviteToken,
} from "../invite/parse";
import { generateKeypair } from "../crypto/keypair";
describe("invite parse", () => {
it("round-trips a signed invite through encode and parse", async () => {
const owner = await generateKeypair();
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const { link, payload } = await buildSignedInvite({
v: 1,
mesh_id: "mesh-abc-123",
mesh_slug: "test-mesh",
broker_url: "wss://broker.example.com",
expires_at: expiresAt,
mesh_root_key: "deadbeefcafebabe",
role: "member",
owner_pubkey: owner.publicKey,
owner_secret_key: owner.secretKey,
});
const parsed = await parseInviteLink(link);
expect(parsed.payload.mesh_id).toBe("mesh-abc-123");
expect(parsed.payload.mesh_slug).toBe("test-mesh");
expect(parsed.payload.broker_url).toBe("wss://broker.example.com");
expect(parsed.payload.expires_at).toBe(expiresAt);
expect(parsed.payload.role).toBe("member");
expect(parsed.payload.owner_pubkey).toBe(owner.publicKey);
expect(parsed.payload.signature).toBe(payload.signature);
});
it("rejects an expired invite", async () => {
const owner = await generateKeypair();
const expiredAt = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
const { link } = await buildSignedInvite({
v: 1,
mesh_id: "mesh-expired",
mesh_slug: "expired-mesh",
broker_url: "wss://broker.example.com",
expires_at: expiredAt,
mesh_root_key: "deadbeef",
role: "member",
owner_pubkey: owner.publicKey,
owner_secret_key: owner.secretKey,
});
await expect(parseInviteLink(link)).rejects.toThrow("invite expired");
});
it("rejects malformed base64 in invite URL", async () => {
// Empty payload after ic://join/ should throw.
expect(() => extractInviteToken("ic://join/")).toThrow("invite link has no payload");
// Short garbage that doesn't match any format should throw.
expect(() => extractInviteToken("!!!not-valid!!!")).toThrow("invalid invite format");
// A sufficiently long but garbage base64url token that decodes to
// invalid JSON should throw at the JSON parse stage.
const garbage = "A".repeat(30); // valid base64url chars, decodes to binary
await expect(parseInviteLink(`ic://join/${garbage}`)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,212 @@
/**
* `claudemesh doctor` — diagnostic checks.
*
* Walks through the install + runtime preconditions and prints each
* as pass/fail with a fix hint on failure. Exit 0 if everything
* passes, 1 otherwise.
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
interface Check {
name: string;
pass: boolean;
detail?: string;
fix?: string;
}
function checkNode(): Check {
const major = Number(process.versions.node.split(".")[0]);
return {
name: "Node.js >= 20",
pass: major >= 20,
detail: `v${process.versions.node}`,
fix: "Install Node 20 or newer (https://nodejs.org)",
};
}
function checkClaudeOnPath(): Check {
const res =
platform() === "win32"
? spawnSync("where", ["claude"])
: spawnSync("sh", ["-c", "command -v claude"]);
const onPath = res.status === 0;
const location = onPath ? res.stdout.toString().trim().split("\n")[0] : undefined;
return {
name: "claude binary on PATH",
pass: onPath,
detail: location,
fix: "Install Claude Code (https://claude.com/claude-code)",
};
}
function checkMcpRegistered(): Check {
const claudeConfig = join(homedir(), ".claude.json");
if (!existsSync(claudeConfig)) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
fix: "Run `claudemesh install`",
};
}
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
const registered = Boolean(cfg.mcpServers?.["claudemesh"]);
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: registered,
fix: registered ? undefined : "Run `claudemesh install`",
};
} catch (e) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Check ~/.claude.json for JSON parse errors",
};
}
}
function checkHooksRegistered(): Check {
const settings = join(homedir(), ".claude", "settings.json");
if (!existsSync(settings)) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
fix: "Run `claudemesh install` (remove --no-hooks)",
};
}
try {
const raw = readFileSync(settings, "utf-8");
const has = raw.includes("claudemesh hook ");
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: has,
fix: has ? undefined : "Run `claudemesh install` (remove --no-hooks)",
};
} catch (e) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
function checkConfigFile(): Check {
const path = getConfigPath();
if (!existsSync(path)) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: true,
detail: "not created yet (fine — no meshes joined)",
};
}
try {
loadConfig();
const st = statSync(path);
const mode = (st.mode & 0o777).toString(8);
const secure = platform() === "win32" || mode === "600";
return {
name: "~/.claudemesh/config.json parses + chmod 0600",
pass: secure,
detail: platform() === "win32" ? "chmod skipped on Windows" : `0${mode}`,
fix: secure ? undefined : `chmod 600 ${path}`,
};
} catch (e) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Inspect or delete ~/.claudemesh/config.json and re-join",
};
}
}
function checkKeypairs(): Check {
try {
const cfg = loadConfig();
if (cfg.meshes.length === 0) {
return {
name: "Mesh keypairs valid",
pass: true,
detail: "no meshes joined",
};
}
for (const m of cfg.meshes) {
if (m.pubkey.length !== 64 || !/^[0-9a-f]+$/.test(m.pubkey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: pubkey malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
if (m.secretKey.length !== 128 || !/^[0-9a-f]+$/.test(m.secretKey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: secret key malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
}
return {
name: "Mesh keypairs valid",
pass: true,
detail: `${cfg.meshes.length} mesh(es)`,
};
} catch (e) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
export async function runDoctor(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
console.log(`claudemesh doctor (v${VERSION})`);
console.log("─".repeat(60));
const checks: Check[] = [
checkNode(),
checkClaudeOnPath(),
checkMcpRegistered(),
checkHooksRegistered(),
checkConfigFile(),
checkKeypairs(),
];
for (const c of checks) {
const mark = c.pass ? green("✓") : red("✗");
const detail = c.detail ? dim(` (${c.detail})`) : "";
console.log(`${mark} ${c.name}${detail}`);
if (!c.pass && c.fix) {
console.log(dim(`${c.fix}`));
}
}
const failing = checks.filter((c) => !c.pass);
console.log("");
if (failing.length === 0) {
console.log(green("All checks passed."));
process.exit(0);
} else {
console.log(red(`${failing.length} check(s) failed.`));
process.exit(1);
}
}

View File

@@ -0,0 +1,123 @@
/**
* `claudemesh hook <status>` — Claude Code hook handler.
*
* Registered as a Stop + UserPromptSubmit hook by `claudemesh install`.
* On each turn boundary, Claude Code invokes:
*
* Stop → `claudemesh hook idle`
* UserPromptSubmit → `claudemesh hook working`
*
* We read the Claude Code hook JSON payload from stdin (contains cwd +
* session_id), then POST `/hook/set-status` to EVERY joined mesh's
* broker with {cwd, pid, status, session_id}. Each broker looks up
* its local presence row by (pid, cwd) and updates status.
*
* Fire-and-forget, silent. Hooks must NEVER block Claude Code or
* surface errors to the user. Debug logging available via
* CLAUDEMESH_HOOK_DEBUG=1.
*
* Why send to every broker? A user joined to multiple meshes has
* one presence row per mesh, each on its own broker. A turn boundary
* updates the status on every broker where this session is active.
* Brokers that don't have a matching presence just queue the signal
* in pending_status (harmless, TTL-swept).
*/
import { loadConfig } from "../state/config";
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
function debug(msg: string): void {
if (DEBUG) console.error(`[claudemesh-hook] ${msg}`);
}
/** WS URL → HTTP URL (same host, swap scheme). */
function wsToHttp(wsUrl: string): string {
try {
const u = new URL(wsUrl);
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
return `${httpScheme}//${u.host}`;
} catch {
return wsUrl;
}
}
async function readStdinJson(): Promise<Record<string, unknown>> {
if (process.stdin.isTTY) return {};
const chunks: Uint8Array[] = [];
const reader = process.stdin;
try {
for await (const chunk of reader) {
chunks.push(chunk as Uint8Array);
if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break;
}
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) return {};
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
async function postHook(
brokerWsUrl: string,
body: Record<string, unknown>,
): Promise<void> {
const base = wsToHttp(brokerWsUrl);
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 1000);
await fetch(`${base}/hook/set-status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(t));
} catch (e) {
debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`);
}
}
export async function runHook(args: string[]): Promise<void> {
const status = args[0];
if (!status || !["idle", "working", "dnd"].includes(status)) {
// Silent no-op — we never want a hook to surface an error.
process.exit(0);
}
// Read Claude Code's stdin payload for cwd + session_id.
const stdinTimeout = new Promise<Record<string, unknown>>((r) =>
setTimeout(() => r({}), 500),
);
const payload = await Promise.race([readStdinJson(), stdinTimeout]);
const cwd =
(typeof payload.cwd === "string" && payload.cwd) ||
process.env.CLAUDE_PROJECT_DIR ||
process.cwd();
const sessionId =
(typeof payload.session_id === "string" && payload.session_id) || "";
// Fan out to EVERY joined mesh's broker in parallel.
let config;
try {
config = loadConfig();
} catch (e) {
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
process.exit(0);
}
if (config.meshes.length === 0) {
debug("no joined meshes, nothing to do");
process.exit(0);
}
const body = { cwd, pid: process.ppid, status, session_id: sessionId };
debug(
`status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`,
);
// Dedupe by brokerUrl — if multiple meshes share a broker, one POST
// covers them (broker resolves presence by cwd+pid regardless).
const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))];
await Promise.all(brokerUrls.map((url) => postHook(url, body)));
process.exit(0);
}

View File

@@ -1,36 +1,394 @@
/**
* `claudemesh install` — print Claude Code MCP registration instructions.
* `claudemesh install` / `uninstall` — manage Claude Code MCP registration.
*
* In the v1 flow, users copy-paste a `claude mcp add ...` command.
* Later we'll auto-write the MCP entry to ~/.claude.json and hooks
* to ~/.claude/settings.json (mirroring claude-intercom's installer).
* install:
* 1. Preflight: bun is on PATH, this package's MCP entry is on disk.
* 2. Read ~/.claude.json (or empty object if absent).
* 3. Add/update `mcpServers.claudemesh` with the resolved entry path.
* 4. Write back with 0600 perms.
* 5. Verify via read-back, print success.
*
* uninstall:
* 1. Read ~/.claude.json (bail if missing).
* 2. Delete `mcpServers.claudemesh` if present.
* 3. Write back.
*
* Both are idempotent — re-running install is a no-op if the entry is
* already correct, and uninstall is a no-op if no entry exists.
*/
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { homedir, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { spawnSync } from "node:child_process";
export function runInstall(): void {
// Resolve the path to this package's own index.ts so the generated
// command points at the right binary even when installed globally.
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
const HOOK_COMMAND_STOP = "claudemesh hook idle";
const HOOK_COMMAND_USER_PROMPT = "claudemesh hook working";
const HOOK_MARKER = "claudemesh hook ";
type McpEntry = {
command: string;
args?: string[];
env?: Record<string, string>;
};
interface HookCommand {
type: "command";
command: string;
}
interface HookMatcher {
matcher?: string;
hooks: HookCommand[];
}
type HooksConfig = Record<string, HookMatcher[]>;
function readClaudeConfig(): Record<string, unknown> {
if (!existsSync(CLAUDE_CONFIG)) return {};
const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_CONFIG}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
/**
* Create a timestamped backup of ~/.claude.json before any write.
*/
function backupClaudeConfig(): void {
if (!existsSync(CLAUDE_CONFIG)) return;
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
mkdirSync(backupDir, { recursive: true });
const ts = Date.now();
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
copyFileSync(CLAUDE_CONFIG, dest);
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns the action taken ("added" | "updated" | "unchanged").
*/
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers =
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
if (!cfg.mcpServers) cfg.mcpServers = servers;
const existing = servers[MCP_NAME];
let action: "added" | "updated" | "unchanged";
if (!existing) {
servers[MCP_NAME] = entry;
action = "added";
} else if (entriesEqual(existing, entry)) {
return "unchanged";
} else {
servers[MCP_NAME] = entry;
action = "updated";
}
flushClaudeConfig(cfg);
return action;
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns true if an entry was removed.
*/
function removeMcpServer(): boolean {
if (!existsSync(CLAUDE_CONFIG)) return false;
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
if (!servers || !(MCP_NAME in servers)) return false;
delete servers[MCP_NAME];
cfg.mcpServers = servers;
flushClaudeConfig(cfg);
return true;
}
/** Low-level write — callers must backup + merge first. */
function flushClaudeConfig(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
writeFileSync(
CLAUDE_CONFIG,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
try {
chmodSync(CLAUDE_CONFIG, 0o600);
} catch {
/* windows has no chmod */
}
}
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
function bunAvailable(): boolean {
const res =
platform() === "win32"
? spawnSync("where", ["bun"])
: spawnSync("sh", ["-c", "command -v bun"]);
return res.status === 0;
}
/** Absolute path to this CLI's entry file. */
function resolveEntry(): string {
const here = fileURLToPath(import.meta.url);
const entry = resolve(dirname(here), "..", "index.ts");
// When bundled (dist/index.js), this file IS the entry → return self.
// When running from source (src/index.ts via bun), walk up to the
// dir + resolve index.ts.
if (here.endsWith("/dist/index.js") || here.endsWith("\\dist\\index.js")) {
return here;
}
return resolve(dirname(here), "..", "index.ts");
}
/**
* Build the MCP server entry for Claude Code's config.
*
* Two modes:
* - Installed globally (npm i -g claudemesh-cli): use `claudemesh`
* as the command, relies on it being on PATH.
* - Local dev (bun apps/cli/src/index.ts): use `bun <absolute-path>`.
*/
function buildMcpEntry(entryPath: string): McpEntry {
const isBundled = entryPath.endsWith("/dist/index.js") ||
entryPath.endsWith("\\dist\\index.js");
if (isBundled) {
return {
command: "claudemesh",
args: ["mcp"],
};
}
return {
command: "bun",
args: [entryPath, "mcp"],
};
}
function entriesEqual(a: McpEntry, b: McpEntry): boolean {
return (
a.command === b.command &&
JSON.stringify(a.args ?? []) === JSON.stringify(b.args ?? [])
);
}
function readClaudeSettings(): Record<string, unknown> {
if (!existsSync(CLAUDE_SETTINGS)) return {};
const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
function writeClaudeSettings(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
writeFileSync(
CLAUDE_SETTINGS,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
}
/**
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
* idempotent on the command string. Returns counts for reporting.
*/
function installHooks(): { added: number; unchanged: number } {
const settings = readClaudeSettings();
const hooks = ((settings.hooks ??= {}) as HooksConfig) ?? {};
let added = 0;
let unchanged = 0;
const ensure = (event: string, command: string): void => {
const list = (hooks[event] ??= []);
const alreadyPresent = list.some((entry) =>
(entry.hooks ?? []).some((h) => h.command === command),
);
if (alreadyPresent) {
unchanged += 1;
return;
}
list.push({ hooks: [{ type: "command", command }] });
added += 1;
};
ensure("Stop", HOOK_COMMAND_STOP);
ensure("UserPromptSubmit", HOOK_COMMAND_USER_PROMPT);
settings.hooks = hooks;
writeClaudeSettings(settings);
return { added, unchanged };
}
/**
* Remove every hook entry whose command contains "claudemesh hook "
* from ~/.claude/settings.json. Idempotent. Returns removed count.
*/
function uninstallHooks(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const hooks = settings.hooks as HooksConfig | undefined;
if (!hooks) return 0;
let removed = 0;
for (const event of Object.keys(hooks)) {
const kept: HookMatcher[] = [];
for (const entry of hooks[event] ?? []) {
const filtered = (entry.hooks ?? []).filter(
(h) => !(h.command ?? "").includes(HOOK_MARKER),
);
removed += (entry.hooks ?? []).length - filtered.length;
if (filtered.length > 0) kept.push({ ...entry, hooks: filtered });
}
if (kept.length === 0) delete hooks[event];
else hooks[event] = kept;
}
settings.hooks = hooks;
writeClaudeSettings(settings);
return removed;
}
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
console.log("claudemesh install");
console.log("------------------");
const entry = resolveEntry();
const isBundled = entry.endsWith("/dist/index.js") ||
entry.endsWith("\\dist\\index.js");
// Dev mode (running from src/) requires bun on PATH; bundled mode
// (npm install -g) just uses node + the claudemesh bin shim.
if (!isBundled && !bunAvailable()) {
console.error(
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
);
process.exit(1);
}
if (!existsSync(entry)) {
console.error(`✗ MCP entry not found at ${entry}`);
process.exit(1);
}
const desired = buildMcpEntry(entry);
const action = patchMcpServer(desired);
// Read-back verification.
const verify = readClaudeConfig();
const verifyServers = (verify.mcpServers ?? {}) as Record<string, McpEntry>;
const stored = verifyServers[MCP_NAME];
if (!stored || !entriesEqual(stored, desired)) {
console.error(
`✗ post-write verification failed — ${CLAUDE_CONFIG} may be corrupt`,
);
process.exit(1);
}
// ANSI color helpers — stick to 8-color set so terminals without
// truecolor still render. Fall back to plain if NO_COLOR or dumb TERM.
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
console.log(dim(` config: ${CLAUDE_CONFIG}`));
console.log(
dim(
` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`,
),
);
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
if (!skipHooks) {
try {
const { added, unchanged } = installHooks();
if (added > 0) {
console.log(
`✓ Hooks registered (Stop + UserPromptSubmit) → ${added} added, ${unchanged} already present`,
);
} else {
console.log(`✓ Hooks already registered (${unchanged} present)`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
);
console.error(
" (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)",
);
}
} else {
console.log(dim("· Hooks skipped (--no-hooks)"));
}
console.log("claudemesh — MCP registration");
console.log("------------------------------");
console.log("");
console.log("Register the MCP server with Claude Code:");
console.log("");
console.log(` claude mcp add claudemesh --scope user -- bun ${entry} mcp`);
console.log("");
console.log("Or if installed globally:");
console.log("");
console.log(` claude mcp add claudemesh --scope user -- claudemesh mcp`);
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
console.log("");
console.log(
"After registering, restart Claude Code. Then join a mesh with:",
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
);
console.log("");
console.log(" claudemesh join <invite-link>");
console.log("");
console.log("(Auto-install of hooks + MCP entry will ship in a later step.)");
console.log(
yellow("⚠ For real-time push messages from peers, launch with:"),
);
console.log(
` ${bold("claudemesh launch")}` +
dim(" (or: claude --dangerously-load-development-channels server:claudemesh)"),
);
console.log(
dim(" Plain `claude` still works — messages are then pull-only via check_messages."),
);
}
export function runUninstall(): void {
console.log("claudemesh uninstall");
console.log("--------------------");
// MCP entry — only removes claudemesh, never touches other servers.
if (removeMcpServer()) {
console.log(`✓ MCP server "${MCP_NAME}" removed`);
} else {
console.log(`· MCP server "${MCP_NAME}" not present`);
}
// Hooks
try {
const removed = uninstallHooks();
if (removed > 0) {
console.log(`✓ Hooks removed (${removed} entries)`);
} else {
console.log("· No claudemesh hooks to remove");
}
} catch (e) {
console.error(
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
console.log("");
console.log("Restart Claude Code to drop the MCP connection + hooks.");
}

View File

@@ -14,14 +14,19 @@ import { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
import { hostname } from "node:os";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "../env";
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-link>");
console.error("Usage: claudemesh join <invite-url-or-token>");
console.error("");
console.error("Example: claudemesh join ic://join/eyJ2IjoxLC4uLn0");
console.error(
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
);
process.exit(1);
}
@@ -76,6 +81,16 @@ export async function runJoin(args: string[]): Promise<void> {
});
saveConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
try {
mkdirSync(dirname(inviteFile), { recursive: true });
writeFileSync(inviteFile, link, "utf-8");
} catch {
// Non-fatal — launch will fall back to shared identity.
}
// 5. Report.
console.log("");
console.log(

View File

@@ -0,0 +1,374 @@
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Flow:
* 1. Parse --name, --join, --mesh, --quiet flags
* 2. If --join: run join flow first (accepts token or URL)
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
import { tmpdir, hostname } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
// --- Arg parsing ---
interface LaunchArgs {
name: string | null;
role: string | null;
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
joinLink: string | null;
meshSlug: string | null;
quiet: boolean;
skipPermConfirm: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
role: null,
groups: null,
joinLink: null,
meshSlug: null,
quiet: false,
skipPermConfirm: false,
claudeArgs: [],
};
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--name" && i + 1 < argv.length) {
result.name = argv[++i]!;
} else if (arg.startsWith("--name=")) {
result.name = arg.slice("--name=".length);
} else if (arg === "--role" && i + 1 < argv.length) {
result.role = argv[++i]!;
} else if (arg.startsWith("--role=")) {
result.role = arg.slice("--role=".length);
} else if (arg === "--groups" && i + 1 < argv.length) {
result.groups = argv[++i]!;
} else if (arg.startsWith("--groups=")) {
result.groups = arg.slice("--groups=".length);
} else if (arg === "--join" && i + 1 < argv.length) {
result.joinLink = argv[++i]!;
} else if (arg.startsWith("--join=")) {
result.joinLink = arg.slice("--join=".length);
} else if (arg === "--mesh" && i + 1 < argv.length) {
result.meshSlug = argv[++i]!;
} else if (arg.startsWith("--mesh=")) {
result.meshSlug = arg.slice("--mesh=".length);
} else if (arg === "--quiet") {
result.quiet = true;
} else if (arg === "-y" || arg === "--yes") {
result.skipPermConfirm = true;
} else if (arg === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
// --- Group string parser ---
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
function parseGroupsString(raw: string): GroupEntry[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((token) => {
const idx = token.indexOf(":");
if (idx === -1) return { name: token };
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
});
}
// --- Interactive role/groups prompts ---
function askLine(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// --- Permission confirmation ---
async function confirmPermissions(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(yellow(bold(" Autonomous mode")));
console.log("");
console.log(" Claude will send and receive peer messages without asking");
console.log(" you first. Peers exchange text only — no file access,");
console.log(" no tool calls, no code execution.");
console.log("");
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
console.log(dim(" Skip this prompt: claudemesh launch -y"));
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve, reject) => {
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
if (a === "" || a === "y" || a === "yes") {
resolve();
} else {
console.log("\n Aborted. Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
});
});
}
// --- Banner ---
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[]): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const roleSuffix = role ? ` (${role})` : "";
const groupTags = groups.length
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags}`));
console.log(rule);
console.log("Peer messages arrive as <channel> reminders in real-time.");
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule);
console.log("");
}
// --- Main ---
export async function runLaunch(extraArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs);
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = loadConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { saveConfig } = await import("../state/config");
saveConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = loadConfig();
if (config.meshes.length === 0) {
console.error(
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
);
process.exit(1);
}
let mesh: JoinedMesh;
if (args.meshSlug) {
const found = config.meshes.find((m) => m.slug === args.meshSlug);
if (!found) {
console.error(
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else {
mesh = await pickMesh(config.meshes);
}
// 3. Session identity + role/groups.
// The WS client auto-generates a per-session ephemeral keypair on
// connect (sent in hello as sessionPubkey). We set display name via env var.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
if (!args.quiet) {
if (role === null) {
const answer = await askLine(" Role (optional): ");
if (answer) role = answer;
}
if (parsedGroups.length === 0 && args.groups === null) {
const answer = await askLine(" Groups (comma-separated, optional): ");
if (answer) parsedGroups = parseGroupsString(answer);
}
if (role || parsedGroups.length) console.log("");
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
const tmpBase = tmpdir();
try {
for (const entry of readdirSync(tmpBase)) {
if (!entry.startsWith("claudemesh-")) continue;
const full = join(tmpBase, entry);
const age = Date.now() - statSync(full).mtimeMs;
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
}
} catch { /* best effort */ }
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
displayName,
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Banner + permission confirmation.
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups);
// Auto-permissions confirmation — needed for autonomous peer messaging.
if (!args.skipPermConfirm) {
await confirmPermissions();
}
}
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
// Strip any user-supplied --dangerously flags to avoid duplicates.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
continue;
}
filtered.push(args.claudeArgs[i]!);
}
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
"--dangerously-skip-permissions",
...filtered,
];
const isWindows = process.platform === "win32";
const child = spawn("claude", claudeArgs, {
stdio: "inherit",
shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
},
});
// 7. Cleanup on exit.
const cleanup = (): void => {
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
};
child.on("error", (err: NodeJS.ErrnoException) => {
cleanup();
if (err.code === "ENOENT") {
console.error(
"✗ `claude` not found on PATH. Install Claude Code first.",
);
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
}
process.exit(1);
});
child.on("exit", (code, signal) => {
cleanup();
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
// Cleanup on parent signals too.
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
process.on("SIGINT", () => { cleanup(); process.exit(0); });
}

View File

@@ -9,7 +9,9 @@ export function runList(): void {
if (config.meshes.length === 0) {
console.log("No meshes joined yet.");
console.log("");
console.log("Join one with: claudemesh join <invite-link>");
console.log(
"Join one with: claudemesh join https://claudemesh.com/join/<token>",
);
console.log(`Config file: ${getConfigPath()}`);
return;
}

View File

@@ -0,0 +1,103 @@
/**
* `claudemesh status` — one-shot health report.
*
* Reports CLI version, config path + permissions, each joined mesh
* with broker reachability (WS handshake probe). Exit 0 if every
* mesh's broker is reachable, 1 otherwise.
*/
import { statSync, existsSync } from "node:fs";
import WebSocket from "ws";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
interface MeshStatus {
slug: string;
brokerUrl: string;
pubkey: string;
reachable: boolean;
error?: string;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
resolve({ ok: false, error: "timeout" });
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true });
});
ws.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, error: err.message });
});
});
}
export async function runStatus(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
console.log(`claudemesh status (v${VERSION})`);
console.log("─".repeat(60));
const configPath = getConfigPath();
let configPerms = "missing";
if (existsSync(configPath)) {
const st = statSync(configPath);
const mode = (st.mode & 0o777).toString(8).padStart(4, "0");
configPerms = mode === "0600" ? `${mode}` : `${mode} ⚠ (expected 0600)`;
}
console.log(`Config: ${configPath} (${configPerms})`);
const config = loadConfig();
if (config.meshes.length === 0) {
console.log("");
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
process.exit(0);
}
console.log("");
console.log(`Meshes (${config.meshes.length}):`);
const results: MeshStatus[] = [];
for (const m of config.meshes) {
process.stdout.write(` ${m.slug.padEnd(20)} probing ${m.brokerUrl}`);
const probe = await probeBroker(m.brokerUrl);
results.push({
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
});
if (probe.ok) {
console.log(green("reachable"));
} else {
console.log(red(`unreachable (${probe.error})`));
}
}
console.log("");
for (const r of results) {
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}`));
}
const allOk = results.every((r) => r.reachable);
console.log("");
if (allOk) {
console.log(green("All meshes reachable."));
process.exit(0);
} else {
const broken = results.filter((r) => !r.reachable).length;
console.log(red(`${broken} of ${results.length} mesh(es) unreachable.`));
process.exit(1);
}
}

View File

@@ -0,0 +1,111 @@
/**
* Stateful welcome screen — shown when the user runs `claudemesh`
* with no arguments. Detects install state + joined meshes + prints
* the next action they should take.
*
* States, in priority order:
* 1. MCP not registered in ~/.claude.json → run install
* 2. Config dir exists but no meshes joined → run join
* 3. Meshes joined, all reachable → run launch
* 4. Meshes joined, broker unreachable → run status / doctor
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "../state/config";
import { VERSION } from "../version";
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
function detectState(): State {
// 1. MCP registered?
const claudeConfig = join(homedir(), ".claude.json");
let mcpRegistered = false;
if (existsSync(claudeConfig)) {
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
} catch {
/* treat parse errors as not-registered */
}
}
if (!mcpRegistered) return "no-install";
// 2. Config parseable + has meshes?
try {
const cfg = loadConfig();
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
} catch {
return "broken-config";
}
}
export function runWelcome(): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
console.log("─".repeat(60));
const state = detectState();
switch (state) {
case "no-install":
console.log("Welcome. Let's get you set up.");
console.log("");
console.log(bold("Step 1:") + " register the MCP server + status hooks");
console.log(` ${green("$")} claudemesh install`);
console.log("");
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
console.log(dim("Step 3: claudemesh launch"));
break;
case "no-meshes":
console.log(green("✓") + " MCP registered. Now join a mesh.");
console.log("");
console.log(bold("Step 2:") + " join a mesh");
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
console.log("");
console.log(
dim(" Don't have an invite? Create one at ") +
bold("https://claudemesh.com") +
dim(" or ask a mesh owner."),
);
console.log("");
console.log(dim("Step 3 (after joining): claudemesh launch"));
break;
case "ready": {
const cfg = loadConfig();
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
console.log(green("✓") + " MCP registered.");
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
console.log("");
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
console.log(` ${green("$")} claudemesh launch`);
console.log("");
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
console.log("");
console.log(dim("Health check: claudemesh status"));
console.log(dim("Diagnostics: claudemesh doctor"));
console.log(dim("All commands: claudemesh --help"));
break;
}
case "broken-config":
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
console.log("");
console.log("Run diagnostics to see what's wrong:");
console.log(` ${green("$")} claudemesh doctor`);
break;
}
console.log("");
}

View File

@@ -1,27 +1,23 @@
import { z } from "zod";
/**
* CLI environment config.
*
* Read once at startup. Overridable via env vars so users can point
* at a self-hosted broker or a staging instance without rebuilding.
*/
const envSchema = z.object({
CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"),
CLAUDEMESH_CONFIG_DIR: z.string().optional(),
CLAUDEMESH_DEBUG: z.coerce.boolean().default(false),
});
export type CliEnv = z.infer<typeof envSchema>;
export interface CliEnv {
CLAUDEMESH_BROKER_URL: string;
CLAUDEMESH_CONFIG_DIR: string | undefined;
CLAUDEMESH_DEBUG: boolean;
}
export function loadEnv(): CliEnv {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("[claudemesh] invalid environment:");
console.error(z.treeifyError(parsed.error));
process.exit(1);
}
return parsed.data;
return {
CLAUDEMESH_BROKER_URL:
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};
}
export const env = loadEnv();

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env bun
/**
* @claudemesh/cli entry point.
* claudemesh-cli entry point.
*
* Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport)
@@ -10,25 +9,42 @@
*/
import { startMcpServer } from "./mcp/server";
import { runInstall } from "./commands/install";
import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join";
import { runList } from "./commands/list";
import { runLeave } from "./commands/leave";
import { runSeedTestMesh } from "./commands/seed-test-mesh";
import { runHook } from "./commands/hook";
import { runLaunch } from "./commands/launch";
import { runStatus } from "./commands/status";
import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome";
import { VERSION } from "./version";
const HELP = `claudemesh — peer mesh for Claude Code sessions
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
Usage:
claudemesh <command> [args]
Commands:
install Print Claude Code MCP registration instructions
join <link> Join a mesh via invite link (ic://join/...)
install Register MCP + Stop/UserPromptSubmit status hooks
(add --no-hooks for bare MCP registration)
uninstall Remove MCP server + hooks
launch [opts] Launch Claude Code with real-time push messages
--name <name> Display name for this session
--mesh <slug> Select mesh (picker if >1, omitted)
--join <url> Join a mesh before launching
--quiet Skip the info banner
-- <args> Pass remaining args to claude
join <url> Join a mesh via https://claudemesh.com/join/... URL
list Show all joined meshes
leave <slug> Leave a joined mesh
status Health report: broker reachability per joined mesh
doctor Diagnostic checks (install, config, keypairs, PATH)
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
mcp Start MCP server (stdio) — invoked by Claude Code
--help, -h Show this help
--version, -v Show the CLI version
Environment:
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
@@ -45,7 +61,16 @@ async function main(): Promise<void> {
await startMcpServer();
return;
case "install":
runInstall();
runInstall(args);
return;
case "uninstall":
runUninstall();
return;
case "hook":
await runHook(args);
return;
case "launch":
await runLaunch(args);
return;
case "join":
await runJoin(args);
@@ -56,15 +81,28 @@ async function main(): Promise<void> {
case "leave":
runLeave(args);
return;
case "status":
await runStatus();
return;
case "doctor":
await runDoctor();
return;
case "seed-test-mesh":
runSeedTestMesh(args);
return;
case "--version":
case "-v":
case "version":
console.log(VERSION);
return;
case "--help":
case "-h":
case "help":
case undefined:
console.log(HELP);
return;
case undefined:
runWelcome();
return;
default:
console.error(`Unknown command: ${cmd}`);
console.error("Run `claudemesh --help` for usage.");

View File

@@ -5,22 +5,19 @@
* verification and one-time-use invite-token tracking land in Step 18.
*/
import { z } from "zod";
import { ensureSodium } from "../crypto/keypair";
const invitePayloadSchema = z.object({
v: z.literal(1),
mesh_id: z.string().min(1),
mesh_slug: z.string().min(1),
broker_url: z.string().min(1),
expires_at: z.number().int().positive(),
mesh_root_key: z.string().min(1),
role: z.enum(["admin", "member"]),
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
signature: z.string().regex(/^[0-9a-f]{128}$/i),
});
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
export interface InvitePayload {
v: 1;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
signature: string;
}
export interface ParsedInvite {
payload: InvitePayload;
@@ -28,6 +25,21 @@ export interface ParsedInvite {
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
}
function validatePayload(obj: unknown): InvitePayload {
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
const o = obj as Record<string, unknown>;
if (o.v !== 1) throw new Error("invite payload: v must be 1");
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
return o as unknown as InvitePayload;
}
/** Canonical invite bytes — must match broker's canonicalInvite(). */
export function canonicalInvite(p: {
v: number;
@@ -42,14 +54,41 @@ export function canonicalInvite(p: {
return `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`;
}
export async function parseInviteLink(link: string): Promise<ParsedInvite> {
if (!link.startsWith("ic://join/")) {
throw new Error(
`invalid invite link: expected prefix "ic://join/", got "${link.slice(0, 20)}…"`,
);
/**
* Extract the raw base64url token from any accepted invite input.
*
* Accepts three formats:
* - `ic://join/<token>` (dev-era scheme, still supported)
* - `https://claudemesh.com/join/<token>` (clickable landing page)
* - `https://claudemesh.com/<locale>/join/<token>` (i18n prefix)
* - `<token>` (raw base64url, last resort)
*/
export function extractInviteToken(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith("ic://join/")) {
const token = trimmed.slice("ic://join/".length).replace(/\/$/, "");
if (!token) throw new Error("invite link has no payload");
return token;
}
const encoded = link.slice("ic://join/".length);
if (!encoded) throw new Error("invite link has no payload");
const httpsMatch = trimmed.match(
/^https?:\/\/[^/]+(?:\/[a-z]{2})?\/join\/([A-Za-z0-9_-]+)\/?$/,
);
if (httpsMatch) return httpsMatch[1]!;
// Last resort: treat as raw base64url token.
if (/^[A-Za-z0-9_-]+$/.test(trimmed) && trimmed.length > 20) {
return trimmed;
}
throw new Error(
`invalid invite format. Expected one of:\n` +
` https://claudemesh.com/join/<token>\n` +
` ic://join/<token>\n` +
` <raw-token>\n` +
`Got: "${input.slice(0, 40)}${input.length > 40 ? "…" : ""}"`,
);
}
export async function parseInviteLink(link: string): Promise<ParsedInvite> {
const encoded = extractInviteToken(link);
let json: string;
try {
@@ -69,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
);
}
const parsed = invitePayloadSchema.safeParse(obj);
if (!parsed.success) {
throw new Error(
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
);
}
const payload = validatePayload(obj);
// Expiry check (unix seconds).
const nowSeconds = Math.floor(Date.now() / 1000);
if (parsed.data.expires_at < nowSeconds) {
if (payload.expires_at < nowSeconds) {
throw new Error(
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
);
}
// Verify the ed25519 signature against the embedded owner_pubkey.
// Client-side verification gives immediate feedback on tampered
// links; broker re-verifies authoritatively on /join.
const s = await ensureSodium();
const canonical = canonicalInvite({
v: parsed.data.v,
mesh_id: parsed.data.mesh_id,
mesh_slug: parsed.data.mesh_slug,
broker_url: parsed.data.broker_url,
expires_at: parsed.data.expires_at,
mesh_root_key: parsed.data.mesh_root_key,
role: parsed.data.role,
owner_pubkey: parsed.data.owner_pubkey,
v: payload.v,
mesh_id: payload.mesh_id,
mesh_slug: payload.mesh_slug,
broker_url: payload.broker_url,
expires_at: payload.expires_at,
mesh_root_key: payload.mesh_root_key,
role: payload.role,
owner_pubkey: payload.owner_pubkey,
});
const sigOk = (() => {
try {
return s.crypto_sign_verify_detached(
s.from_hex(parsed.data.signature),
s.from_hex(payload.signature),
s.from_string(canonical),
s.from_hex(parsed.data.owner_pubkey),
s.from_hex(payload.owner_pubkey),
);
} catch {
return false;
@@ -113,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
throw new Error("invite signature invalid (link tampered?)");
}
return { payload: parsed.data, raw: link, token: encoded };
return { payload, raw: link, token: encoded };
}
/**
@@ -128,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
/**
* Sign and assemble an invite payload → ic://join/... link.
* The canonical bytes (everything except signature) are signed with
* the mesh owner's ed25519 secret key.
*/
export async function buildSignedInvite(args: {
v: 1;

View File

@@ -1,12 +1,8 @@
/**
* MCP server (stdio transport) for @claudemesh/cli.
* MCP server (stdio transport) for claudemesh-cli.
*
* Starts BrokerClient connections for every mesh in config on boot,
* then routes the 5 MCP tools through them.
*
* list_peers is stubbed at the CLI level — the broker's WS protocol
* does not yet carry a list-peers request type (Step 16). Until then,
* it returns a note.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -37,61 +33,153 @@ function text(msg: string, isError = false) {
/**
* Given a `to` string, pick which mesh to send from. Strategies:
* - If `to` looks like a pubkey hex (64 chars), try every client;
* caller is expected to know which mesh the pubkey lives in.
* - If `to` starts with `#`, treat as channel on the first mesh.
* - Otherwise try to match a displayName (TODO — needs list_peers).
* - If `to` looks like a pubkey hex (64 chars), use as-is.
* - If `to` starts with `#`, treat as channel.
* - If `to` is `*`, treat as broadcast.
* - Otherwise resolve as a display name via list_peers.
*
* For now the MVP: if only one mesh is joined, use that. Otherwise
* require the caller to prefix with `<mesh-slug>:`.
* Explicit mesh prefix `<mesh-slug>:<target>` narrows to one mesh.
*/
function resolveClient(to: string): {
async function resolveClient(to: string): Promise<{
client: BrokerClient | null;
targetSpec: string;
error?: string;
} {
}> {
const clients = allClients();
if (clients.length === 0) {
return { client: null, targetSpec: to, error: "no meshes joined" };
}
// Explicit mesh prefix: "mesh-slug:targetspec"
let targetClients = clients;
let target = to;
const colonIdx = to.indexOf(":");
if (colonIdx > 0 && colonIdx < to.length - 1) {
const slug = to.slice(0, colonIdx);
const rest = to.slice(colonIdx + 1);
const match = findClient(slug);
if (match) return { client: match, targetSpec: rest };
if (match) {
targetClients = [match];
target = rest;
}
}
// Single-mesh fast path.
if (clients.length === 1) {
return { client: clients[0]!, targetSpec: to };
// Pubkey, channel, @group, or broadcast — pass through directly.
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target.startsWith("@") || target === "*") {
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
return {
client: null,
targetSpec: target,
error: `multiple meshes joined; prefix target with "<mesh-slug>:" (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
};
}
// Name-based resolution: query each mesh's peer list for a matching displayName.
const nameLower = target.toLowerCase();
for (const c of targetClients) {
const peers = await c.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === nameLower);
if (match) return { client: c, targetSpec: match.pubkey };
// Partial match: if only one peer's name contains the search string.
const partials = peers.filter((p) =>
p.displayName.toLowerCase().includes(nameLower),
);
if (partials.length === 1) {
return { client: c, targetSpec: partials[0]!.pubkey };
}
}
// Single-mesh fallback: let the broker try to resolve it.
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
return {
client: null,
targetSpec: to,
error: `multiple meshes joined; prefix target with "<mesh-slug>:" (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
targetSpec: target,
error: `peer "${target}" not found in any mesh (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
};
}
// Peer name cache to avoid calling listPeers on every incoming push
const peerNameCache = new Map<string, string>();
let peerNameCacheAge = 0;
const CACHE_TTL_MS = 30_000;
async function resolvePeerName(client: BrokerClient, pubkey: string): Promise<string> {
const now = Date.now();
if (now - peerNameCacheAge > CACHE_TTL_MS) {
peerNameCache.clear();
try {
const peers = await client.listPeers();
for (const p of peers) peerNameCache.set(p.pubkey, p.displayName);
} catch { /* best effort */ }
peerNameCacheAge = now;
}
return peerNameCache.get(pubkey) ?? `peer-${pubkey.slice(0, 8)}`;
}
function decryptFailedWarning(senderPubkey: string): string {
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
}
function formatPush(p: InboundPush, meshSlug: string): string {
const body = p.plaintext ?? "(decryption failed)";
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
}
export async function startMcpServer(): Promise<void> {
const config = loadConfig();
const myName = config.displayName ?? "unnamed";
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const server = new Server(
{ name: "claudemesh", version: "0.1.0" },
{ name: "claudemesh", version: "0.3.0" },
{
capabilities: { tools: {} },
instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions.
capabilities: {
experimental: { "claude/channel": {} },
tools: {},
},
instructions: `## Identity
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
Use these tools to coordinate with peers on demand. Respond promptly when you receive messages (they're like someone tapping your shoulder).
## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
Tools: send_message, list_peers, check_messages, set_summary, set_status.
## Tools
| Tool | Description |
|------|-------------|
| send_message(to, message, priority?) | Send to peer name, @group, or * broadcast. \`to\` accepts display name, pubkey hex, @groupname, or *. |
| list_peers(mesh_slug?) | List connected peers with status, summary, groups, and roles. |
| check_messages() | Drain buffered inbound messages (auto-pushed in most cases, use as fallback). |
| set_summary(summary) | Set 1-2 sentence description of your current work, visible to all peers. |
| set_status(status) | Override status: idle, working, or dnd. |
| join_group(name, role?) | Join a @group with optional role (lead, member, observer, or any string). |
| leave_group(name) | Leave a @group. |
| set_state(key, value) | Write shared state; pushes change to all peers. |
| get_state(key) | Read a shared state value. |
| list_state() | List all state keys with values, authors, and timestamps. |
| remember(content, tags?) | Store persistent knowledge with optional tags. |
| recall(query) | Full-text search over mesh memory. |
| forget(id) | Soft-delete a memory entry. |
If you have multiple joined meshes, prefix the \`to\` argument of send_message with \`<mesh-slug>:\` to disambiguate. Otherwise claudemesh picks the single joined mesh.`,
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
## Groups
Groups are routing labels. Send to @groupname to multicast to all members. Roles are metadata that peers interpret: a "lead" gathers input before synthesizing a response, a "member" contributes when asked, an "observer" watches silently. Join and leave groups dynamically with join_group/leave_group. Check list_peers to see who belongs to which groups and their roles.
## State
Shared key-value store scoped to the mesh. Use get_state/set_state for live coordination facts (deploy frozen? current sprint? PR queue). set_state pushes the change to all connected peers. Read state before asking peers questions — the answer may already be there. State is operational, not archival.
## Memory
Persistent knowledge that survives across sessions. Use remember(content, tags?) to store lessons, decisions, and incidents. Use recall(query) to search before asking peers. New peers should recall at session start to load institutional knowledge.
## Priority
- "now": interrupt immediately, even if recipient is in DND (use for urgent: broken deploy, blocking issue)
- "next" (default): deliver when recipient goes idle (normal coordination)
- "low": pull-only via check_messages (FYI, non-blocking context)
## Coordination
Call list_peers at session start to understand who is online, their roles, and what they are working on. If you are a group lead, gather input from members before responding to external requests — do not answer alone. If you are a member, contribute to your lead when asked. Use @group messages for team-wide questions, direct messages for 1:1 coordination. Set a meaningful summary so peers know your current focus.`,
},
);
@@ -103,7 +191,7 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
const { name, arguments: args } = req.params;
if (config.meshes.length === 0) {
return text(
"No meshes joined. Run `claudemesh join <invite-link>` first.",
"No meshes joined. Run `claudemesh join https://claudemesh.com/join/<token>` first.",
true,
);
}
@@ -113,7 +201,7 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
if (!to || !message)
return text("send_message: `to` and `message` required", true);
const { client, targetSpec, error } = resolveClient(to);
const { client, targetSpec, error } = await resolveClient(to);
if (!client)
return text(`send_message: ${error ?? "no client resolved"}`, true);
const result = await client.send(
@@ -143,12 +231,39 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
: "list_peers: no joined meshes",
true,
);
const lines = clients.map(
(c) =>
`- ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`,
const sections: string[] = [];
for (const c of clients) {
const peers = await c!.listPeers();
const header = `## ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`;
if (peers.length === 0) {
sections.push(`${header}\nNo peers connected.`);
} else {
const peerLines = peers.map((p) => {
const summary = p.summary ? ` — "${p.summary}"` : "";
const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr} (${p.pubkey.slice(0, 12)}…)${summary}`;
});
sections.push(`${header}\n${peerLines.join("\n")}`);
}
}
return text(sections.join("\n\n"));
}
case "message_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("message_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("message_status: not connected", true);
const result = await client.messageStatus(id);
if (!result) return text(`Message ${id} not found or timed out.`);
const recipientLines = result.recipients.map(
(r: { name: string; pubkey: string; status: string }) =>
` - ${r.name} (${r.pubkey.slice(0, 12)}…): ${r.status}`,
);
return text(
`Connected meshes:\n${lines.join("\n")}\n\n(list_peers WS protocol lands in Step 16; only mesh status is shown for now.)`,
`Message ${id.slice(0, 12)}… → ${result.targetSpec}\n` +
`Delivered: ${result.delivered}${result.deliveredAt ? ` at ${result.deliveredAt}` : ""}\n` +
`Recipients:\n${recipientLines.join("\n")}`,
);
}
@@ -167,8 +282,9 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
case "set_summary": {
const { summary } = (args ?? {}) as SetSummaryArgs;
if (!summary) return text("set_summary: `summary` required", true);
for (const c of allClients()) await c.setSummary(summary);
return text(
`set_summary: summary recorded locally ("${summary}"). (Broker WS protocol for summaries lands in Step 16.)`,
`Summary set: "${summary}" (visible to ${allClients().length} mesh(es)).`,
);
}
@@ -180,6 +296,73 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
return text(`Status set to ${s} across ${allClients().length} mesh(es).`);
}
case "join_group": {
const { name: groupName, role } = (args ?? {}) as { name?: string; role?: string };
if (!groupName) return text("join_group: `name` required", true);
for (const c of allClients()) await c.joinGroup(groupName, role);
return text(`Joined @${groupName}${role ? ` as ${role}` : ""}`);
}
case "leave_group": {
const { name: groupName } = (args ?? {}) as { name?: string };
if (!groupName) return text("leave_group: `name` required", true);
for (const c of allClients()) await c.leaveGroup(groupName);
return text(`Left @${groupName}`);
}
// --- State ---
case "set_state": {
const { key, value } = (args ?? {}) as { key?: string; value?: unknown };
if (!key) return text("set_state: `key` required", true);
for (const c of allClients()) await c.setState(key, value);
return text(`State set: ${key} = ${JSON.stringify(value)}`);
}
case "get_state": {
const { key } = (args ?? {}) as { key?: string };
if (!key) return text("get_state: `key` required", true);
const client = allClients()[0];
if (!client) return text("get_state: not connected", true);
const result = await client.getState(key);
if (!result) return text(`State "${key}" not found.`);
return text(`${key} = ${JSON.stringify(result.value)} (set by ${result.updatedBy} at ${result.updatedAt})`);
}
case "list_state": {
const client = allClients()[0];
if (!client) return text("list_state: not connected", true);
const entries = await client.listState();
if (entries.length === 0) return text("No shared state set.");
const lines = entries.map(e => `- **${e.key}** = ${JSON.stringify(e.value)} (by ${e.updatedBy})`);
return text(lines.join("\n"));
}
// --- Memory ---
case "remember": {
const { content, tags } = (args ?? {}) as { content?: string; tags?: string[] };
if (!content) return text("remember: `content` required", true);
const client = allClients()[0];
if (!client) return text("remember: not connected", true);
const id = await client.remember(content, tags);
return text(`Remembered${id ? ` (${id})` : ""}: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}"`);
}
case "recall": {
const { query } = (args ?? {}) as { query?: string };
if (!query) return text("recall: `query` required", true);
const client = allClients()[0];
if (!client) return text("recall: not connected", true);
const memories = await client.recall(query);
if (memories.length === 0) return text(`No memories found for "${query}".`);
const lines = memories.map(m => `- [${m.id.slice(0, 8)}] ${m.content} (by ${m.rememberedBy}, ${m.rememberedAt})`);
return text(`${memories.length} memor${memories.length === 1 ? 'y' : 'ies'}:\n${lines.join("\n")}`);
}
case "forget": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("forget: `id` required", true);
const client = allClients()[0];
if (!client) return text("forget: not connected", true);
await client.forget(id);
return text(`Forgotten: ${id}`);
}
default:
return text(`Unknown tool: ${name}`, true);
}
@@ -191,6 +374,56 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
const transport = new StdioServerTransport();
await server.connect(transport);
// Wire WSS pushes → MCP channel notifications. Each inbound push on
// any mesh's broker connection becomes a <channel source="claudemesh">
// system reminder injected into Claude Code's context.
for (const client of allClients()) {
client.onPush(async (msg) => {
const fromPubkey = msg.senderPubkey || "";
// Resolve sender's display name from the cached peer list.
const fromName = fromPubkey
? await resolvePeerName(client, fromPubkey)
: "unknown";
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
from_id: fromPubkey,
from_name: fromName,
mesh_slug: client.meshSlug,
mesh_id: client.meshId,
priority: msg.priority,
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
},
},
});
} catch {
/* channel push is best-effort; check_messages is the fallback */
}
});
client.onStateChange(async (change) => {
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[state] ${change.key} = ${JSON.stringify(change.value)} (set by ${change.updatedBy})`,
meta: {
kind: "state_change",
key: change.key,
updated_by: change.updatedBy,
},
},
});
} catch { /* best effort */ }
});
}
const shutdown = (): void => {
stopAll();
process.exit(0);

View File

@@ -12,13 +12,13 @@ export const TOOLS: Tool[] = [
{
name: "send_message",
description:
"Send a message to a peer in one of your joined meshes. `to` is a peer display name, hex pubkey, or `#channel`. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
inputSchema: {
type: "object",
properties: {
to: {
type: "string",
description: "Peer name, pubkey, or #channel",
description: "Peer name, pubkey, @group, or #channel",
},
message: { type: "string", description: "Message text" },
priority: {
@@ -44,6 +44,21 @@ export const TOOLS: Tool[] = [
},
},
},
{
name: "message_status",
description:
"Check the delivery status of a sent message. Shows whether each recipient received it.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Message ID (returned by send_message)",
},
},
required: ["id"],
},
},
{
name: "check_messages",
description:
@@ -78,4 +93,106 @@ export const TOOLS: Tool[] = [
required: ["status"],
},
},
{
name: "join_group",
description:
"Join a group with an optional role. Other peers see your group membership in list_peers.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Group name (without @)" },
role: {
type: "string",
description: "Your role in the group (e.g. lead, member, observer)",
},
},
required: ["name"],
},
},
{
name: "leave_group",
description: "Leave a group.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Group name (without @)" },
},
required: ["name"],
},
},
// --- State tools ---
{
name: "set_state",
description:
"Set a shared state value visible to all peers in the mesh. Pushes a change notification.",
inputSchema: {
type: "object",
properties: {
key: { type: "string" },
value: { description: "Any JSON value" },
},
required: ["key", "value"],
},
},
{
name: "get_state",
description: "Read a shared state value.",
inputSchema: {
type: "object",
properties: {
key: { type: "string" },
},
required: ["key"],
},
},
{
name: "list_state",
description: "List all shared state keys and values in the mesh.",
inputSchema: { type: "object", properties: {} },
},
// --- Memory tools ---
{
name: "remember",
description:
"Store persistent knowledge in the mesh's shared memory. Survives across sessions.",
inputSchema: {
type: "object",
properties: {
content: {
type: "string",
description: "The knowledge to remember",
},
tags: {
type: "array",
items: { type: "string" },
description: "Optional categorization tags",
},
},
required: ["content"],
},
},
{
name: "recall",
description: "Search the mesh's shared memory by relevance.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
},
{
name: "forget",
description: "Remove a memory from the mesh's shared knowledge.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Memory ID to forget" },
},
required: ["id"],
},
},
];

View File

@@ -15,38 +15,45 @@ import {
} from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { z } from "zod";
import { env } from "../env";
const joinedMeshSchema = z.object({
meshId: z.string(),
memberId: z.string(),
slug: z.string(),
name: z.string(),
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars)
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars)
brokerUrl: z.string(),
joinedAt: z.string(),
});
export interface JoinedMesh {
meshId: string;
memberId: string;
slug: string;
name: string;
pubkey: string; // ed25519 hex (32 bytes = 64 chars)
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
brokerUrl: string;
joinedAt: string;
}
const configSchema = z.object({
version: z.literal(1).default(1),
meshes: z.array(joinedMeshSchema).default([]),
});
export interface GroupEntry {
name: string;
role?: string;
}
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
export type Config = z.infer<typeof configSchema>;
export interface Config {
version: 1;
meshes: JoinedMesh[];
displayName?: string; // per-session override, written by `claudemesh launch --name`
groups?: GroupEntry[];
}
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
export function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
return configSchema.parse({ version: 1, meshes: [] });
return { version: 1, meshes: [] };
}
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
return configSchema.parse(JSON.parse(raw));
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] };
}
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups };
} catch (e) {
throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

8
apps/cli/src/version.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Bundled version string. Bun inlines the package.json JSON at build
* time, so the shipped binary carries the exact version that was
* published.
*/
import pkg from "../package.json" with { type: "json" };
export const VERSION: string = pkg.version;

View File

@@ -21,10 +21,21 @@ import {
isDirectTarget,
} from "../crypto/envelope";
import { signHello } from "../crypto/hello-sig";
import { generateKeypair } from "../crypto/keypair";
export type Priority = "now" | "next" | "low";
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
export interface PeerInfo {
pubkey: string;
displayName: string;
status: string;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
}
export interface InboundPush {
messageId: string;
meshId: string;
@@ -64,6 +75,14 @@ export class BrokerClient {
private outbound: Array<() => void> = []; // closures that send once ws is open
private pushHandlers = new Set<PushHandler>();
private pushBuffer: InboundPush[] = [];
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
private stateResolvers: Array<(result: { key: string; value: unknown; updatedBy: string; updatedAt: string } | null) => void> = [];
private stateListResolvers: Array<(entries: Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) => void> = [];
private memoryStoreResolvers: Array<(id: string | null) => void> = [];
private memoryRecallResolvers: Array<(memories: Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) => void> = [];
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private closed = false;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
@@ -73,6 +92,7 @@ export class BrokerClient {
private mesh: JoinedMesh,
private opts: {
onStatusChange?: (status: ConnStatus) => void;
displayName?: string;
debug?: boolean;
} = {},
) {}
@@ -93,14 +113,21 @@ export class BrokerClient {
/** Open WS, send hello, resolve when hello_ack received. */
async connect(): Promise<void> {
if (this.closed) throw new Error("client is closed");
this.setStatus("connecting");
this.setConnStatus("connecting");
const ws = new WebSocket(this.mesh.brokerUrl);
this.ws = ws;
return new Promise<void>((resolve, reject) => {
const onOpen = async (): Promise<void> => {
this.debug("ws open → signing + sending hello");
this.debug("ws open → generating session keypair + signing hello");
try {
// Only generate session keypair on first connect, not reconnects
if (!this.sessionPubkey) {
const sessionKP = await generateKeypair();
this.sessionPubkey = sessionKP.publicKey;
this.sessionSecretKey = sessionKP.secretKey;
}
const { timestamp, signature } = await signHello(
this.mesh.meshId,
this.mesh.memberId,
@@ -113,6 +140,8 @@ export class BrokerClient {
meshId: this.mesh.meshId,
memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey,
sessionPubkey: this.sessionPubkey,
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || this.opts.displayName || undefined,
sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
@@ -146,7 +175,7 @@ export class BrokerClient {
if (msg.type === "hello_ack") {
if (this.helloTimer) clearTimeout(this.helloTimer);
this.helloTimer = null;
this.setStatus("open");
this.setConnStatus("open");
this.reconnectAttempt = 0;
this.flushOutbound();
resolve();
@@ -163,7 +192,7 @@ export class BrokerClient {
reject(new Error("ws closed before hello_ack"));
}
if (!this.closed) this.scheduleReconnect();
else this.setStatus("closed");
else this.setConnStatus("closed");
};
const onError = (err: Error): void => {
@@ -192,7 +221,7 @@ export class BrokerClient {
const env = await encryptDirect(
message,
targetSpec,
this.mesh.secretKey,
this.sessionSecretKey ?? this.mesh.secretKey,
);
nonce = env.nonce;
ciphertext = env.ciphertext;
@@ -266,6 +295,142 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "set_status", status }));
}
/** Request the list of connected peers from the broker. */
async listPeers(): Promise<PeerInfo[]> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.listPeersResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_peers" }));
// Timeout after 5s — return empty list rather than hang.
setTimeout(() => {
const idx = this.listPeersResolvers.indexOf(resolve);
if (idx !== -1) {
this.listPeersResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Update this session's summary visible to other peers. */
async setSummary(summary: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
}
/** Join a group with an optional role. */
async joinGroup(name: string, role?: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "join_group", name, role }));
}
/** Leave a group. */
async leaveGroup(name: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "leave_group", name }));
}
// --- State ---
/** Set a shared state value visible to all peers in the mesh. */
async setState(key: string, value: unknown): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "set_state", key, value }));
}
/** Read a shared state value. */
async getState(key: string): Promise<{ key: string; value: unknown; updatedBy: string; updatedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.stateResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_state", key }));
setTimeout(() => {
const idx = this.stateResolvers.indexOf(resolve);
if (idx !== -1) {
this.stateResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
});
}
/** List all shared state keys and values. */
async listState(): Promise<Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.stateListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_state" }));
setTimeout(() => {
const idx = this.stateListResolvers.indexOf(resolve);
if (idx !== -1) {
this.stateListResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
// --- Memory ---
/** Store persistent knowledge in the mesh's shared memory. */
async remember(content: string, tags?: string[]): Promise<string | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.memoryStoreResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "remember", content, tags }));
setTimeout(() => {
const idx = this.memoryStoreResolvers.indexOf(resolve);
if (idx !== -1) {
this.memoryStoreResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
});
}
/** Search the mesh's shared memory by relevance. */
async recall(query: string): Promise<Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.memoryRecallResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "recall", query }));
setTimeout(() => {
const idx = this.memoryRecallResolvers.indexOf(resolve);
if (idx !== -1) {
this.memoryRecallResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Remove a memory from the mesh's shared knowledge. */
async forget(memoryId: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "forget", memoryId }));
}
/** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.messageStatusResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "message_status", messageId }));
setTimeout(() => {
const idx = this.messageStatusResolvers.indexOf(resolve);
if (idx !== -1) { this.messageStatusResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
});
}
/** Subscribe to state change notifications. Returns an unsubscribe function. */
onStateChange(handler: (change: { key: string; value: unknown; updatedBy: string }) => void): () => void {
this.stateChangeHandlers.add(handler);
return () => this.stateChangeHandlers.delete(handler);
}
close(): void {
this.closed = true;
if (this.helloTimer) clearTimeout(this.helloTimer);
@@ -277,7 +442,7 @@ export class BrokerClient {
/* ignore */
}
}
this.setStatus("closed");
this.setConnStatus("closed");
}
// --- Internals ---
@@ -294,6 +459,12 @@ export class BrokerClient {
}
return;
}
if (msg.type === "peers_list") {
const peers = (msg.peers as PeerInfo[]) ?? [];
const resolver = this.listPeersResolvers.shift();
if (resolver) resolver(peers);
return;
}
if (msg.type === "push") {
const nonce = String(msg.nonce ?? "");
const ciphertext = String(msg.ciphertext ?? "");
@@ -309,19 +480,36 @@ export class BrokerClient {
plaintext = await decryptDirect(
{ nonce, ciphertext },
senderPubkey,
this.mesh.secretKey,
this.sessionSecretKey ?? this.mesh.secretKey,
);
}
// If decryption failed, fall back to base64 UTF-8 unwrap —
// this covers the legacy plaintext path for broadcasts/channels
// until channel crypto lands.
if (plaintext === null && ciphertext) {
// Legacy/broadcast path: no senderPubkey means the message
// was not crypto_box'd, so base64 UTF-8 unwrap is correct.
// For direct messages (senderPubkey present) we MUST NOT
// base64-decode the ciphertext on decrypt failure — that
// produces garbage binary that surfaces as garbled bytes
// to Claude. Leave plaintext=null and let consumers emit
// a clear "failed to decrypt" warning.
if (plaintext === null && ciphertext && !senderPubkey) {
try {
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
} catch {
plaintext = null;
}
}
// Fallback: if direct decrypt failed, try plaintext base64 decode.
// This handles broadcasts and key mismatches gracefully.
if (plaintext === null && ciphertext) {
try {
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
// Sanity check: valid UTF-8 text (not binary garbage)
if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) {
plaintext = decoded;
}
} catch {
plaintext = null;
}
}
const push: InboundPush = {
messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""),
@@ -346,6 +534,55 @@ export class BrokerClient {
})();
return;
}
if (msg.type === "state_result") {
const resolver = this.stateResolvers.shift();
if (resolver) {
if (msg.key) {
resolver({
key: String(msg.key),
value: msg.value,
updatedBy: String(msg.updatedBy ?? ""),
updatedAt: String(msg.updatedAt ?? ""),
});
} else {
resolver(null);
}
}
return;
}
if (msg.type === "state_list") {
const entries = (msg.entries as Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) ?? [];
const resolver = this.stateListResolvers.shift();
if (resolver) resolver(entries);
return;
}
if (msg.type === "state_change") {
const change = {
key: String(msg.key ?? ""),
value: msg.value,
updatedBy: String(msg.updatedBy ?? ""),
};
for (const h of this.stateChangeHandlers) {
try { h(change); } catch { /* handler errors are not the transport's problem */ }
}
return;
}
if (msg.type === "memory_stored") {
const resolver = this.memoryStoreResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
return;
}
if (msg.type === "memory_results") {
const memories = (msg.memories as Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) ?? [];
const resolver = this.memoryRecallResolvers.shift();
if (resolver) resolver(memories);
return;
}
if (msg.type === "message_status_result") {
const resolver = this.messageStatusResolvers.shift();
if (resolver) resolver(msg as any);
return;
}
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null;
@@ -369,7 +606,7 @@ export class BrokerClient {
}
private scheduleReconnect(): void {
this.setStatus("reconnecting");
this.setConnStatus("reconnecting");
const delay =
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
this.reconnectAttempt += 1;
@@ -384,7 +621,7 @@ export class BrokerClient {
}, delay);
}
private setStatus(s: ConnStatus): void {
private setConnStatus(s: ConnStatus): void {
if (this._status === s) return;
this._status = s;
this.opts.onStatusChange?.(s);

View File

@@ -11,12 +11,13 @@ import type { Config, JoinedMesh } from "../state/config";
import { env } from "../env";
const clients = new Map<string, BrokerClient>();
let configDisplayName: string | undefined;
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
const existing = clients.get(mesh.meshId);
if (existing) return existing;
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG });
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG, displayName: configDisplayName });
clients.set(mesh.meshId, client);
try {
await client.connect();
@@ -29,6 +30,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
/** Start clients for every joined mesh. Called once on MCP server start. */
export async function startClients(config: Config): Promise<void> {
configDisplayName = config.displayName;
await Promise.allSettled(config.meshes.map(ensureClient));
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
},
});

View File

@@ -31,7 +31,7 @@ NEXT_PUBLIC_AUTH_MAGIC_LINK="false"
NEXT_PUBLIC_AUTH_PASSKEY="true"
# Use this variable to enable or disable anonymous authentication. If you set this to true, users will be able to proceed to your app without "traditional" authentication. If you set this to false, the anonymous login won't be available.
NEXT_PUBLIC_AUTH_ANONYMOUS="true"
NEXT_PUBLIC_AUTH_ANONYMOUS="false"
# Auth server secret - used to sign the tokens
BETTER_AUTH_SECRET="lT4GdPj3OSx00OcTRUdwywn1DNgBBuvK"
@@ -49,7 +49,7 @@ GITHUB_CLIENT_SECRET="<your-github-client-secret>"
# Seed config (used for accounts in development environment)
SEED_EMAIL="me@turbostarter.dev"
SEED_EMAIL="dev@example.com"
SEED_PASSWORD="Pa\$\$w0rd"

View File

@@ -40,7 +40,7 @@ export default defineEnv({
NEXT_PUBLIC_AUTH_PASSWORD: castStringToBool.optional().default(true),
NEXT_PUBLIC_AUTH_MAGIC_LINK: castStringToBool.optional().default(false),
NEXT_PUBLIC_AUTH_PASSKEY: castStringToBool.optional().default(true),
NEXT_PUBLIC_AUTH_ANONYMOUS: castStringToBool.optional().default(true),
NEXT_PUBLIC_AUTH_ANONYMOUS: castStringToBool.optional().default(false),
NEXT_PUBLIC_PRODUCT_NAME: z.string().optional().default("claudemesh"),
NEXT_PUBLIC_URL: z.string().url().optional().default("http://localhost:3000"),

View File

@@ -80,6 +80,12 @@ const config: NextConfig = {
serverExternalPackages: [
"better-sqlite3",
"@mapbox/node-pre-gyp",
"esbuild",
"payload",
"@payloadcms/db-postgres",
"@payloadcms/db-sqlite",
"@payloadcms/richtext-lexical",
"sharp",
],
turbopack: {
rules: {

View File

@@ -18,8 +18,12 @@
"@anaralabs/lector": "3.7.3",
"@formatjs/intl-localematcher": "0.6.2",
"@hookform/resolvers": "5.2.2",
"@next/bundle-analyzer": "16.0.10",
"@next/bundle-analyzer": "16.2.2",
"@number-flow/react": "0.5.10",
"@payloadcms/db-postgres": "3.81.0",
"@payloadcms/db-sqlite": "^3.81.0",
"@payloadcms/next": "^3.81.0",
"@payloadcms/richtext-lexical": "^3.81.0",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-table": "catalog:",
@@ -40,10 +44,11 @@
"marked": "16.4.1",
"motion": "12.23.24",
"negotiator": "1.0.0",
"next": "16.0.10",
"next": "16.2.2",
"next-i18n-router": "5.5.5",
"next-themes": "0.4.6",
"nuqs": "2.7.2",
"payload": "^3.81.0",
"pdfjs-dist": "5.4.530",
"qrcode": "1.5.4",
"react": "catalog:react19",
@@ -57,6 +62,7 @@
"rehype-raw": "7.0.0",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"sharp": "0.34.5",
"sonner": "2.0.7",
"zod": "catalog:",
"zustand": "5.0.8"

212
apps/web/payload.config.ts Normal file
View File

@@ -0,0 +1,212 @@
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { sqliteAdapter } from "@payloadcms/db-sqlite";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "path";
import { fileURLToPath } from "url";
import sharp from "sharp";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
// Use Postgres in production (DATABASE_URL), SQLite locally
const usePostgres = !!process.env.DATABASE_URL;
export default buildConfig({
secret: process.env.PAYLOAD_SECRET || "claudemesh-dev-secret-change-in-production",
routes: {
admin: "/payload",
},
admin: {
user: "users",
meta: {
titleSuffix: "— claudemesh",
},
},
editor: lexicalEditor(),
db: usePostgres
? postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL! },
schemaName: "payload",
})
: sqliteAdapter({
client: {
url: process.env.PAYLOAD_DATABASE_URI || `file:${path.resolve(dirname, "payload.db")}`,
},
}),
sharp,
collections: [
// --- Users (admin panel) ---
{
slug: "users",
auth: true,
admin: { useAsTitle: "email" },
fields: [
{ name: "name", type: "text" },
{ name: "role", type: "select", options: ["admin", "editor"], defaultValue: "editor" },
],
},
// --- Media ---
{
slug: "media",
upload: {
staticDir: path.resolve(dirname, "public/media"),
mimeTypes: ["image/*"],
},
admin: { useAsTitle: "alt" },
fields: [
{ name: "alt", type: "text", required: true },
],
},
// --- Authors ---
{
slug: "authors",
admin: { useAsTitle: "name" },
fields: [
{ name: "name", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true },
{ name: "bio", type: "textarea" },
{ name: "role", type: "text" },
{
name: "avatar",
type: "upload",
relationTo: "media",
},
{
name: "links",
type: "group",
fields: [
{ name: "github", type: "text" },
{ name: "twitter", type: "text" },
{ name: "website", type: "text" },
],
},
],
},
// --- Categories ---
{
slug: "categories",
admin: { useAsTitle: "name" },
fields: [
{ name: "name", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true },
{ name: "description", type: "textarea" },
],
},
// --- Blog Posts ---
{
slug: "posts",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "publishedAt", "author"],
},
versions: { drafts: true },
fields: [
{ name: "title", type: "text", required: true },
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
description: "URL-friendly identifier. Auto-generated from title if left blank.",
},
},
{
name: "excerpt",
type: "textarea",
admin: { description: "1-2 sentence summary for cards and meta descriptions." },
},
{
name: "content",
type: "richText",
required: true,
},
{
name: "coverImage",
type: "upload",
relationTo: "media",
},
{
name: "author",
type: "relationship",
relationTo: "authors",
required: true,
},
{
name: "categories",
type: "relationship",
relationTo: "categories",
hasMany: true,
},
{
name: "publishedAt",
type: "date",
admin: { position: "sidebar", date: { pickerAppearance: "dayOnly" } },
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
admin: { position: "sidebar" },
},
{
name: "seo",
type: "group",
fields: [
{ name: "metaTitle", type: "text" },
{ name: "metaDescription", type: "textarea" },
{ name: "ogImage", type: "upload", relationTo: "media" },
],
},
],
},
// --- Changelog ---
{
slug: "changelog",
admin: {
useAsTitle: "version",
defaultColumns: ["version", "date", "type"],
},
fields: [
{ name: "version", type: "text", required: true },
{ name: "date", type: "date", required: true },
{
name: "type",
type: "select",
options: [
{ label: "Feature", value: "feat" },
{ label: "Fix", value: "fix" },
{ label: "Docs", value: "docs" },
{ label: "Breaking", value: "breaking" },
],
required: true,
},
{ name: "summary", type: "text", required: true },
{ name: "body", type: "richText" },
{ name: "npmUrl", type: "text" },
{ name: "githubUrl", type: "text" },
],
},
],
typescript: {
outputFile: path.resolve(dirname, "src/payload-types.ts"),
},
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#141413"/>
<!-- mesh connections -->
<g stroke="#d97757" stroke-width="1" opacity="0.3">
<line x1="180" y1="160" x2="420" y2="280"/>
<line x1="420" y1="280" x2="700" y2="200"/>
<line x1="700" y1="200" x2="950" y2="320"/>
<line x1="180" y1="160" x2="300" y2="400"/>
<line x1="300" y1="400" x2="550" y2="450"/>
<line x1="550" y1="450" x2="700" y2="200"/>
<line x1="550" y1="450" x2="950" y2="320"/>
<line x1="420" y1="280" x2="300" y2="400"/>
<line x1="700" y1="200" x2="850" y2="480"/>
<line x1="950" y1="320" x2="850" y2="480"/>
<line x1="300" y1="400" x2="150" y2="520"/>
<line x1="550" y1="450" x2="850" y2="480"/>
<line x1="1050" y1="150" x2="950" y2="320"/>
<line x1="100" y1="350" x2="180" y2="160"/>
<line x1="100" y1="350" x2="300" y2="400"/>
</g>
<!-- encrypted data flow (dashed) -->
<g stroke="#d97757" stroke-width="1.5" stroke-dasharray="6 8" opacity="0.15">
<line x1="180" y1="160" x2="950" y2="320"/>
<line x1="300" y1="400" x2="700" y2="200"/>
<line x1="100" y1="350" x2="550" y2="450"/>
<line x1="420" y1="280" x2="850" y2="480"/>
</g>
<!-- nodes -->
<g fill="#d97757">
<circle cx="180" cy="160" r="5"/>
<circle cx="420" cy="280" r="5"/>
<circle cx="700" cy="200" r="5"/>
<circle cx="950" cy="320" r="5"/>
<circle cx="300" cy="400" r="5"/>
<circle cx="550" cy="450" r="5"/>
<circle cx="850" cy="480" r="4"/>
<circle cx="1050" cy="150" r="3.5"/>
<circle cx="100" cy="350" r="3.5"/>
<circle cx="150" cy="520" r="3"/>
</g>
<!-- node halos -->
<g fill="none" stroke="#d97757" stroke-width="0.5" opacity="0.2">
<circle cx="180" cy="160" r="16"/>
<circle cx="420" cy="280" r="14"/>
<circle cx="700" cy="200" r="18"/>
<circle cx="950" cy="320" r="15"/>
<circle cx="550" cy="450" r="12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,173 @@
import Link from "next/link";
import { Reveal, SectionIcon } from "~/modules/marketing/home/_reveal";
export const metadata = {
title: "About — claudemesh",
description:
"claudemesh is built by Alejandro A. Gutiérrez Mourente — fighter pilot, AI business architect, solo builder.",
};
export default function AboutPage() {
return (
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<Reveal className="mb-6">
<SectionIcon glyph="leaf" />
</Reveal>
<Reveal delay={1}>
<h1
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
About
</h1>
</Reveal>
<Reveal delay={2}>
<div
className="mt-10 space-y-6 text-[15px] leading-[1.8] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<p>
claudemesh is built by{" "}
<span className="font-medium text-[var(--cm-fg)]">
Alejandro A. Gutiérrez Mourente
</span>{" "}
a fighter pilot who builds production AI systems.
</p>
<p>
A decade flying F-18s and serving as Operational Safety Officer
in the Spanish Air Force taught one thing: systems either work
under pressure or they fail people. That standard followed into
software.
</p>
<p>
Before claudemesh, that meant shipping a document intelligence
platform that replaced a manual process worth 5M/year (four
extraction engines, contract generation, production-grade), AI
backoffice modules for a multi-tenant enterprise platform, and
end-to-end ERP integrations across automotive, aviation, fintech,
legal, and defense each designed, built, and presented to
leadership by one person.
</p>
<p className="text-[var(--cm-fg)]">
claudemesh exists because Claude Code sessions are isolated. You
close the terminal and the context dies. Your teammate re-solves
the same bug. The insight never travels.
</p>
<p>
The fix: a peer mesh. End-to-end encrypted, delivered mid-turn,
broker-never-decrypts. The{" "}
<Link
href="https://github.com/alezmad/claudemesh-cli"
className="text-[var(--cm-clay)] hover:underline"
>
CLI is MIT-licensed
</Link>
. The{" "}
<Link
href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md"
className="text-[var(--cm-clay)] hover:underline"
>
wire protocol is documented
</Link>
. The{" "}
<Link
href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md"
className="text-[var(--cm-clay)] hover:underline"
>
threat model is public
</Link>
.
</p>
<p>
The same safety thinking that goes into clearing a formation
through weather goes into deciding what untrusted text should and
should not reach your AI agent. The stakes are lower. The method
is the same: understand the failure modes first, then build the
system that handles them.
</p>
</div>
</Reveal>
<Reveal delay={3}>
<div className="mt-12 border-t border-[var(--cm-border)] pt-8">
<h2
className="mb-4 text-[18px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Background
</h2>
<div
className="space-y-3 text-[13px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="flex items-start gap-3">
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
Fighter pilot · Spanish Air Force (Ejército del Aire) · F-18
Hornet · Operational Safety Officer (QASO)
</span>
</div>
<div className="flex items-start gap-3">
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
AI Business Architect · document intelligence, ERP
integration, multi-tenant enterprise platforms
</span>
</div>
<div className="flex items-start gap-3">
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
Full-stack solo builder · TypeScript, Python, LLM
orchestration, domain-driven design
</span>
</div>
<div className="flex items-start gap-3">
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
Regulated industries · automotive, aviation, fintech, legal,
defense
</span>
</div>
<div className="flex items-start gap-3">
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>Las Palmas, Canarias, Spain</span>
</div>
</div>
</div>
</Reveal>
<Reveal delay={4}>
<div className="mt-10 flex flex-wrap gap-4">
<Link
href="https://github.com/alezmad"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
GitHub
</Link>
<Link
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
LinkedIn
</Link>
<Link
href="mailto:info@whyrating.com"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Contact
</Link>
</div>
</Reveal>
</section>
);
}

View File

@@ -0,0 +1,68 @@
import Link from "next/link";
export const metadata = {
title: "Blog — claudemesh",
description: "Engineering notes on peer messaging, protocol design, and multi-agent security.",
};
const POSTS = [
{
slug: "peer-messaging-claude-code",
title: "Peer messaging for Claude Code: protocol, security, UX",
excerpt:
"How claudemesh connects Claude Code sessions over an encrypted mesh, using MCP dev-channels for real-time message injection.",
date: "2026-04-06",
},
];
export default function BlogIndex() {
return (
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<h1
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Blog
</h1>
<p
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Engineering notes on protocol design, security, and multi-agent UX.
</p>
<div className="mt-12 space-y-10">
{POSTS.map((post) => (
<article key={post.slug} className="border-b border-[var(--cm-border)] pb-8">
<time
dateTime={post.date}
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<h2 className="mt-2">
<Link
href={`/blog/${post.slug}`}
className="text-[22px] font-medium leading-tight text-[var(--cm-fg)] transition-colors hover:text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{post.title}
</Link>
</h2>
<p
className="mt-3 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{post.excerpt}
</p>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,194 @@
import Link from "next/link";
export const metadata = {
title: "Peer messaging for Claude Code: protocol, security, UX — claudemesh",
description:
"How claudemesh connects Claude Code sessions over an encrypted mesh, using MCP dev-channels for real-time message injection. Wire protocol, threat model, and what's next.",
openGraph: {
title: "Peer messaging for Claude Code: protocol, security, UX",
description: "How claudemesh connects Claude Code sessions over an encrypted mesh.",
images: ["/media/blog-hero-mesh.png"],
},
};
export default function BlogPost() {
return (
<article className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<header className="mb-12">
<time
dateTime="2026-04-06"
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
April 6, 2026
</time>
<h1
className="mt-3 text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Peer messaging for Claude Code: protocol, security, UX
</h1>
<p
className="mt-4 text-sm text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
by Alejandro A. Gutiérrez Mourente
</p>
</header>
<div
className="space-y-5 text-[15px] leading-[1.8] text-[var(--cm-fg-secondary)] [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:text-[22px] [&_h2]:font-medium [&_h2]:text-[var(--cm-fg)] [&_a]:text-[var(--cm-clay)] [&_a]:hover:underline [&_code]:rounded [&_code]:bg-[var(--cm-gray-800)] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[13px] [&_code]:text-[var(--cm-fg-secondary)] [&_pre]:overflow-x-auto [&_pre]:rounded-[8px] [&_pre]:border [&_pre]:border-[var(--cm-border)] [&_pre]:bg-[var(--cm-gray-850)] [&_pre]:p-4 [&_pre]:text-[13px] [&_pre]:leading-[1.6] [&_strong]:font-medium [&_strong]:text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<p>
Claude Code sessions are islands. You build context over an hour of conversation, close the
tab, and that context dies. Two sessions side by side one refactoring the API, one fixing
the frontend share a filesystem but not a thought. I spent a decade flying F-18s in the
Spanish Air Force, where every formation member broadcasts position, fuel, and threat data
in real time. Silence kills. I built{" "}
<a href="https://github.com/alezmad/claudemesh-cli">claudemesh</a> to give Claude Code
sessions the same link: an MCP server that connects them over an encrypted mesh, pushing
messages directly into each other's context mid-turn.
</p>
<p>
The CLI is MIT-licensed, on npm as <code>claudemesh-cli</code>. This post covers the wire
protocol, the experimental Claude Code capability behind real-time injection, and the
prompt-injection surface that deserves careful attention.
</p>
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>The protocol</h2>
<p>
One owner's ed25519 public key defines a mesh. The owner generates signed invite links;
each invitee verifies the signature, generates a fresh ed25519 keypair locally, and enrolls
with a broker via <code>POST /join</code>. The client then opens a persistent WebSocket
(<code>wss://</code> in production) and authenticates with a signed <code>hello</code>{" "}
frame:
</p>
<pre><code>{`{
"type": "hello",
"meshId": "01HX...",
"memberId": "01HX...",
"pubkey": "64-hex-chars",
"timestamp": 1735689600000,
"signature": "128-hex-chars"
}`}</code></pre>
<p>
The signature covers{" "}
<code>{"${meshId}|${memberId}|${pubkey}|${timestamp}"}</code>. The broker verifies it
against the registered public key and replies <code>hello_ack</code>. The connection is
live.
</p>
<p>
Direct messages use libsodium <code>crypto_box_easy</code> for end-to-end encryption
X25519 keys derived from ed25519 identity pairs via{" "}
<code>crypto_sign_ed25519_pk_to_curve25519</code>. The broker routes ciphertext and never
sees plaintext. Priority routing: <code>now</code> delivers immediately, <code>next</code>{" "}
queues until idle, <code>low</code> waits for an explicit drain. The full specification
lives in{" "}
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md">PROTOCOL.md</a>{" "}
(453 lines).
</p>
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>Dev channels: the missing piece</h2>
<p>
An experimental Claude Code capability fixes the polling problem:{" "}
<code>notifications/claude/channel</code>. When an MCP server declares{" "}
<code>{"{ experimental: { \"claude/channel\": {} } }"}</code> and Claude Code launches
with <code>--dangerously-load-development-channels server:&lt;name&gt;</code>, the server
pushes notifications that arrive as <code>{"<channel source=\"claudemesh\">"}</code> system
reminders mid-turn. Claude reacts immediately.
</p>
<p>
<code>claudemesh launch</code> wraps this into one command. I tested with an echo-channel
MCP server emitting a notification every 15 seconds all three ticks arrived mid-turn and
Claude responded inline. Confirmed on Claude Code v2.1.92.
</p>
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>The prompt-injection question</h2>
<p>
This section matters most. claudemesh decrypts peer text and injects it into Claude's
context. That text is untrusted input. A peer can send instruction overrides, tool-call
steering, or confused-deputy attacks invoking other MCP servers through Claude. The same
failure-mode analysis that clears a formation through weather applies here: enumerate every
way the system breaks, then close each path.
</p>
<p>
<strong>Tool-approval prompts stay intact.</strong> claudemesh never disables Claude Code's
permission system. A peer message can ask Claude to run a shell command; Claude still
prompts the user.
</p>
<p>
<strong>Messages carry attribution.</strong> Each <code>{"<channel>"}</code> reminder
includes <code>from_id</code>, <code>from_name</code>, and <code>mesh_slug</code>.
</p>
<p>
<strong>Membership requires a signed invite.</strong> An attacker needs a valid
ed25519-signed invite from the mesh owner or a compromised member keypair.
</p>
<p>
The residual risks are real. If a user blanket-approves tools, a malicious peer message
reaches the shell without human review. The causal chain peer message, Claude decision,
tool call has no persistent audit trail yet.{" "}
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md">
THREAT_MODEL.md
</a>{" "}
(212 lines) documents all of this. Open questions I want to work through with the Claude
Code team.
</p>
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>What I'd do next</h2>
<p>
<strong>Shared-key channel crypto.</strong> Channel and broadcast messages are base64
plaintext today. The upgrade is a KDF from <code>mesh_root_key</code> plus key rotation.
</p>
<p>
<strong>Causal audit log.</strong> When Claude calls a tool because of a peer message, that
link should persist: which message, which tool call, what result.
</p>
<p>
<strong>Sender allowlists.</strong> Per-mesh config: accept messages only from these
pubkeys. If a member's key is compromised, others exclude it locally.
</p>
<p>
<strong>Forward secrecy.</strong> <code>crypto_box</code> uses long-lived keys. A leaked
key lets an attacker decrypt all past captured ciphertext. A double-ratchet would bound the
damage window.
</p>
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>Try it</h2>
<pre><code>{`npm install -g claudemesh-cli
claudemesh install
claudemesh join https://claudemesh.com/join/<token>
claudemesh launch`}</code></pre>
<p>
The code is at{" "}
<a href="https://github.com/alezmad/claudemesh-cli">github.com/alezmad/claudemesh-cli</a>.
The wire protocol is in{" "}
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md">PROTOCOL.md</a>.
The threat model is in{" "}
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md">
THREAT_MODEL.md
</a>.
Contributions welcome see{" "}
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/CONTRIBUTING.md">
CONTRIBUTING.md
</a>.
</p>
<p>
If you work on Claude Code or the MCP ecosystem and this interests you, I'd like to hear
from you.
</p>
</div>
<div className="mt-12 border-t border-[var(--cm-border)] pt-8">
<Link
href="/blog"
className="text-sm text-[var(--cm-clay)] hover:underline"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Back to blog
</Link>
</div>
</article>
);
}

View File

@@ -0,0 +1,55 @@
export const metadata = {
title: "Changelog — claudemesh",
description: "Release history for claudemesh-cli.",
};
const ENTRIES = [
{ version: "0.1.4", date: "2026-04-06", type: "feat", summary: "Stateful welcome screen, PROTOCOL.md, THREAT_MODEL.md, Windows CI matrix" },
{ version: "0.1.3", date: "2026-04-05", type: "feat", summary: "claudemesh --version, status, doctor commands" },
{ version: "0.1.2", date: "2026-04-05", type: "feat", summary: "claudemesh launch command, transparency banner, decrypt fix, Windows support" },
];
const TYPE_LABELS: Record<string, string> = { feat: "Feature", fix: "Fix", docs: "Docs" };
const TYPE_COLORS: Record<string, string> = { feat: "bg-[var(--cm-clay)]", fix: "bg-[var(--cm-cactus)]", docs: "bg-[var(--cm-oat)]" };
export default function ChangelogPage() {
return (
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<h1
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Changelog
</h1>
<p
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Every shipped version of claudemesh-cli.
</p>
<div className="mt-12 space-y-8">
{ENTRIES.map((entry) => (
<article key={entry.version} className="border-b border-[var(--cm-border)] pb-6">
<div className="flex items-center gap-3">
<span
className={`rounded-[4px] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--cm-bg)] ${TYPE_COLORS[entry.type] || "bg-[var(--cm-fg-tertiary)]"}`}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{TYPE_LABELS[entry.type] || entry.type}
</span>
<span className="text-[18px] font-medium text-[var(--cm-fg)]" style={{ fontFamily: "var(--cm-font-serif)" }}>
v{entry.version}
</span>
<time dateTime={entry.date} className="text-[11px] text-[var(--cm-fg-tertiary)]" style={{ fontFamily: "var(--cm-font-mono)" }}>
{new Date(entry.date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}
</time>
</div>
<p className="mt-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]" style={{ fontFamily: "var(--cm-font-sans)" }}>
{entry.summary}
</p>
</article>
))}
</div>
</section>
);
}

View File

@@ -4,10 +4,19 @@ import { Pricing } from "~/modules/marketing/home/pricing";
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
import { Features } from "~/modules/marketing/home/features";
import { MeetsYou } from "~/modules/marketing/home/meets-you";
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
import { FAQ } from "~/modules/marketing/home/faq";
import { CallToAction } from "~/modules/marketing/home/cta";
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
// Revalidate the page every 60s so the mesh-stats counter stays fresh
// without hammering the DB. The /api/public/stats endpoint has its own
// 60s in-memory cache too.
export const revalidate = 60;
const HomePage = () => {
return (
<div
@@ -20,8 +29,12 @@ const HomePage = () => {
<LaptopToLaptop />
<Features />
<MeetsYou />
<WhatIsClaudemesh />
<DemoDashboard />
<BeyondTerminal />
<FAQ />
<CallToAction />
<MeshStats />
<LatestNewsToaster />
</div>
);

View File

@@ -1,35 +1,98 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { Icons } from "@turbostarter/ui-web/icons";
import Link from "next/link";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
export default async function AuthLayout({
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const { t } = await getTranslation({ ns: "common" });
return (
<main className="grid h-full w-full flex-1 lg:grid-cols-2">
<section className="flex h-full flex-col items-center justify-center p-6 lg:p-10">
<header className="text-navy -mt-1 mb-auto flex self-start justify-self-start">
<TurboLink
href={pathsConfig.index}
className="flex shrink-0 items-center gap-3"
aria-label={t("home")}
<main
className="grid min-h-screen w-full flex-1 bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased lg:grid-cols-2"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<section className="relative flex h-full min-h-screen flex-col items-center justify-center px-6 py-10 lg:px-12">
<header className="absolute left-6 top-6 lg:left-12 lg:top-10">
<Link
href="/"
aria-label="claudemesh home"
className="group flex shrink-0 items-center gap-2.5"
>
<Icons.Logo className="text-primary h-8" />
<Icons.LogoText className="text-foreground h-4" />
</TurboLink>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
>
<circle cx="12" cy="4" r="2" fill="currentColor" />
<circle cx="4" cy="12" r="2" fill="currentColor" />
<circle cx="20" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke="currentColor"
strokeWidth="1.2"
opacity="0.45"
/>
</svg>
<span
className="text-[17px] font-medium tracking-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
claudemesh
</span>
</Link>
</header>
<div className="mt-16 mb-auto flex w-full max-w-md flex-col gap-6 pb-16">
{children}
</div>
<div className="flex w-full max-w-md flex-col gap-6">{children}</div>
</section>
<aside className="bg-muted hidden flex-1 lg:block"></aside>
<aside
className="relative hidden overflow-hidden border-l border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] lg:block"
>
<div
className="absolute inset-0 opacity-[0.15]"
style={{
backgroundImage:
"radial-gradient(circle at 50% 50%, var(--cm-clay) 0%, transparent 60%)",
}}
/>
<div className="relative flex h-full flex-col items-center justify-center px-10 py-16 text-center">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
className="mb-8 text-[var(--cm-clay)]"
>
<circle cx="12" cy="4" r="2" fill="currentColor" />
<circle cx="4" cy="12" r="2" fill="currentColor" />
<circle cx="20" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke="currentColor"
strokeWidth="1.2"
opacity="0.45"
/>
</svg>
<h2
className="max-w-sm text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Every Claude Code session,{" "}
<span className="italic text-[var(--cm-clay)]">
woven into one mesh.
</span>
</h2>
<p
className="text-muted-foreground mt-6 max-w-sm text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Connect every Claude Code session on your team into one live mesh.
Ship context, not screenshots.
</p>
</div>
</aside>
</main>
);
}

View File

@@ -41,8 +41,8 @@ export default async function InvitesPage() {
</p>
</div>
) : (
<div className="rounded-lg border">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[560px] text-sm">
<thead className="text-muted-foreground border-b text-left text-xs uppercase">
<tr>
<th className="px-4 py-3 font-medium">Mesh</th>

View File

@@ -13,13 +13,33 @@ export const generateMetadata = getMetadata({
export default async function InvitePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ onboarding?: string }>;
}) {
const { id } = await params;
const { onboarding } = await searchParams;
const isOnboarding = onboarding === "1";
return (
<>
{isOnboarding && (
<div className="border-primary/40 bg-primary/5 mb-6 rounded-lg border p-5">
<h2 className="text-primary mb-1 text-lg font-medium">
Mesh created
</h2>
<p className="mb-2 text-sm leading-relaxed">
Now generate your first invite link to share with a teammate or
use it yourself to join this mesh from another laptop. Your
teammate runs{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
claudemesh join &lt;link&gt;
</code>{" "}
in their terminal.
</p>
</div>
)}
<DashboardHeader>
<div>
<DashboardHeaderTitle>Invite teammate</DashboardHeaderTitle>

View File

@@ -0,0 +1,69 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
export const generateMetadata = getMetadata({
title: "Live mesh",
description: "Real-time situational awareness of your mesh.",
});
export default async function LiveMeshPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Authz gate — same endpoint the detail page uses
const data = await handle(api.my.meshes[":id"].$get, {
schema: getMyMeshResponseSchema,
})({ param: { id } }).catch(() => null);
if (!data || !data.mesh) notFound();
const { mesh } = data;
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>
<span className="flex items-center gap-3">
{mesh.name}
<Badge variant="outline" className="font-mono text-xs">
live
</Badge>
</span>
</DashboardHeaderTitle>
<DashboardHeaderDescription>
Real-time view of presences and envelope routing across this
mesh. Broker sees ciphertext only.
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.mesh(mesh.id)}
className={buttonVariants({ variant: "outline" })}
>
Mesh detail
</Link>
</div>
</DashboardHeader>
<LiveStreamPanel meshId={id} />
</>
);
}

View File

@@ -40,11 +40,11 @@ export default async function MeshPage({
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<div className="flex w-full flex-col items-start gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<DashboardHeaderTitle>
<span className="flex items-center gap-3">
{mesh.name}
<span className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="truncate">{mesh.name}</span>
<Badge variant="outline" className="font-mono text-xs">
{mesh.slug}
</Badge>
@@ -55,12 +55,28 @@ export default async function MeshPage({
· tier {mesh.tier} · {mesh.visibility} · {mesh.transport}
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
className={buttonVariants({ variant: "default" })}
>
Generate invite link
</Link>
<div className="flex w-full gap-2 sm:w-auto">
<Link
href={pathsConfig.dashboard.user.meshes.live(mesh.id)}
className={buttonVariants({
variant: "outline",
className: "flex-1 sm:flex-initial",
})}
>
<span className="mr-1.5 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--cm-clay)]" />
Live
</Link>
<Link
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
className={buttonVariants({
variant: "default",
className: "flex-1 sm:flex-initial",
})}
>
<span className="hidden sm:inline">Generate invite link</span>
<span className="sm:hidden">Invite</span>
</Link>
</div>
</div>
</DashboardHeader>
@@ -81,9 +97,9 @@ export default async function MeshPage({
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between px-4 py-3"
className="flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="font-medium">
{m.displayName}
{m.isMe && (
@@ -131,16 +147,16 @@ export default async function MeshPage({
{activeInvites.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between px-4 py-3 text-sm"
className="flex flex-col gap-1.5 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<code className="bg-muted rounded px-2 py-0.5 text-xs">
{inv.token.slice(0, 12)}
</code>
<Badge variant="outline" className="text-xs">
{inv.role}
</Badge>
<span className="text-muted-foreground">
<span className="text-muted-foreground text-xs">
{inv.usedCount} / {inv.maxUses} used
</span>
</div>

View File

@@ -11,9 +11,29 @@ export const generateMetadata = getMetadata({
description: "Create a mesh.",
});
export default function NewMeshPage() {
export default async function NewMeshPage({
searchParams,
}: {
searchParams: Promise<{ onboarding?: string }>;
}) {
const { onboarding } = await searchParams;
const isOnboarding = onboarding === "1";
return (
<>
{isOnboarding && (
<div className="border-primary/40 bg-primary/5 mb-6 rounded-lg border p-5">
<h2 className="text-primary mb-1 text-lg font-medium">
Welcome to claudemesh
</h2>
<p className="text-sm leading-relaxed">
Create your first mesh in 10 seconds. A mesh is the space where
your Claude Code sessions talk to each other. You can invite
teammates, share context, and route messages all end-to-end
encrypted.
</p>
</div>
)}
<DashboardHeader>
<div>
<DashboardHeaderTitle>New mesh</DashboardHeaderTitle>
@@ -23,7 +43,7 @@ export default function NewMeshPage() {
</div>
</DashboardHeader>
<div className="max-w-xl">
<CreateMeshForm />
<CreateMeshForm onboarding={isOnboarding} />
</div>
</>
);

View File

@@ -1,66 +1,84 @@
"use client";
import Link from "next/link";
import { redirect } from "next/navigation";
import { useTranslation } from "@turbostarter/i18n";
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
import { Icons } from "@turbostarter/ui-web/icons";
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
/**
* Dashboard Home Page
*
* Welcome page for authenticated users.
*/
export default function DashboardPage() {
const { t } = useTranslation("dashboard");
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
export const generateMetadata = getMetadata({
title: "Dashboard",
description: "Your meshes.",
});
export default async function DashboardHomePage() {
const { data } = await handle(api.my.meshes.$get, {
schema: getMyMeshesResponseSchema,
})({
query: { page: "1", perPage: "6", sort: JSON.stringify([]) },
});
// First-time onboarding: 0-mesh user → bounce to create
if (data.length === 0) {
redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`);
}
return (
<div className="@container h-full p-6">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("welcome.title", { defaultValue: "Welcome to your Dashboard" })}
</h1>
<p className="text-muted-foreground">
{t("welcome.description", { defaultValue: "Get started by exploring the features below." })}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("features.aiChat.title", { defaultValue: "AI Chat" })}</CardTitle>
<Icons.MessageSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("features.aiChat.description", { defaultValue: "Have a conversation with AI assistants" })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("features.imageGeneration.title", { defaultValue: "Image Generation" })}</CardTitle>
<Icons.Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("features.imageGeneration.description", { defaultValue: "Create images with AI" })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("features.pdfAnalysis.title", { defaultValue: "PDF Analysis" })}</CardTitle>
<Icons.FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("features.pdfAnalysis.description", { defaultValue: "Upload and analyze PDF documents" })}
</p>
</CardContent>
</Card>
</div>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-medium tracking-tight">Your meshes</h1>
<p className="text-muted-foreground text-sm">
Open one to see its members, generate invites, or share it.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.map((m) => (
<Link
key={m.id}
href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="group-hover:text-primary truncate font-medium">
{m.name}
</h3>
<p className="text-muted-foreground truncate font-mono text-xs">
{m.slug}
</p>
</div>
<Badge variant="outline" className="text-xs">
{m.isOwner ? "owner" : m.myRole}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs">
<Badge variant="secondary" className="text-xs">
{m.tier}
</Badge>
<span className="text-muted-foreground">
{m.memberCount} {m.memberCount === 1 ? "member" : "members"}
</span>
</div>
</Link>
))}
</div>
<div className="flex gap-3">
<Link
href={pathsConfig.dashboard.user.meshes.index}
className={buttonVariants({ variant: "outline" })}
>
All meshes
</Link>
<Link
href={pathsConfig.dashboard.user.meshes.new}
className={buttonVariants({ variant: "default" })}
>
New mesh
</Link>
</div>
</div>
);

View File

@@ -0,0 +1,218 @@
import Link from "next/link";
import {
publicInviteResponseSchema,
type PublicInviteResponse,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { InstallToggle } from "~/modules/join/install-toggle";
export const generateMetadata = getMetadata({
title: "Join a mesh",
description: "You've been invited to a claudemesh mesh.",
});
const ERROR_COPY: Record<
Extract<PublicInviteResponse, { valid: false }>["reason"],
{ title: string; body: (inviter: string | null) => string }
> = {
expired: {
title: "This invite expired",
body: (inviter) =>
`The invite is no longer valid. Ask ${inviter ?? "the person who sent it"} for a fresh link.`,
},
revoked: {
title: "This invite was revoked",
body: (inviter) =>
`${inviter ?? "The mesh owner"} revoked this invite. Ask for a new one if you still need access.`,
},
exhausted: {
title: "This invite has no uses left",
body: (inviter) =>
`Every allowed use has been redeemed. Ask ${inviter ?? "the person who sent it"} for a new link.`,
},
mesh_archived: {
title: "This mesh is no longer active",
body: () => "The mesh was archived. There is nothing to join.",
},
bad_signature: {
title: "This invite is invalid",
body: () =>
"The signature does not verify. The link was modified or forged — ask for a fresh one through a trusted channel.",
},
malformed: {
title: "This invite is unreadable",
body: () =>
"The token could not be decoded. Check the link you received — it may be truncated.",
},
not_found: {
title: "This invite does not exist",
body: () =>
"Nothing matches this token. It may have been deleted, or the link was mis-pasted.",
},
};
export default async function JoinPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const invite = await handle(api.public.invite[":token"].$get, {
schema: publicInviteResponseSchema,
})({ param: { token } }).catch(
() =>
({
valid: false,
reason: "malformed",
meshName: null,
inviterName: null,
expiresAt: null,
}) as const,
);
return (
<main
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<header className="border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
<Link
href="/"
aria-label="claudemesh home"
className="group flex w-fit items-center gap-2.5"
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
>
<circle cx="12" cy="4" r="2" fill="currentColor" />
<circle cx="4" cy="12" r="2" fill="currentColor" />
<circle cx="20" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke="currentColor"
strokeWidth="1.2"
opacity="0.45"
/>
</svg>
<span
className="text-[17px] font-medium tracking-tight"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
claudemesh
</span>
</Link>
</header>
<div className="mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
{invite.valid ? (
<>
<div
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
invitation
</div>
<h1
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
You&apos;re invited to{" "}
<span className="italic text-[var(--cm-clay)]">
{invite.meshName}
</span>
</h1>
<p
className="mt-4 text-lg leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{invite.inviterName
? `${invite.inviterName} added you as a ${invite.role}.`
: `You've been added as a ${invite.role}.`}{" "}
{invite.memberCount} other{" "}
{invite.memberCount === 1 ? "peer is" : "peers are"} already on
the mesh.
</p>
<div className="mt-12">
<InstallToggle token={invite.token} />
</div>
<div
className="mt-14 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
By joining, you&apos;ll be known as a peer with an ed25519
keypair generated locally. You keep your keys. claudemesh sees
ciphertext only. Leave anytime with{" "}
<code
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
claudemesh leave {invite.meshSlug}
</code>
.
</div>
<p
className="mt-8 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
remaining
</p>
</>
) : (
<>
<div
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
invitation unavailable
</div>
<h1
className="text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{ERROR_COPY[invite.reason].title}
</h1>
<p
className="mt-4 text-base leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{ERROR_COPY[invite.reason].body(invite.inviterName)}
</p>
{invite.meshName && (
<p
className="mt-2 text-sm text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
mesh: {invite.meshName}
{invite.expiresAt &&
` · expired ${new Date(invite.expiresAt).toLocaleDateString()}`}
</p>
)}
<div className="mt-10">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
claudemesh.com
</Link>
</div>
</>
)}
</div>
</main>
);
}

View File

@@ -7,7 +7,6 @@ import { Providers } from "~/lib/providers/providers";
import { ImpersonatingBanner } from "~/modules/admin/users/user/impersonating-banner";
import { BaseLayout } from "~/modules/common/layout/base";
import { Toaster } from "~/modules/common/toast";
import { BuyCtaDialog } from "~/modules/marketing/layout/buy-cta-dialog";
export function generateStaticParams() {
return config.locales.map((locale) => ({ locale }));
@@ -33,7 +32,6 @@ export default async function RootLayout({
<Providers locale={locale}>
<ImpersonatingBanner />
{children}
<BuyCtaDialog />
<Toaster />
</Providers>
</BaseLayout>

View File

@@ -0,0 +1,100 @@
/**
* GET /install — serves a shell installer for claudemesh-cli.
*
* Intended to be piped into bash:
* curl -fsSL https://claudemesh.com/install | bash
*
* The script is kept short + auditable. It does not try to install
* Node for the user — it checks for a compatible Node + npm and
* directs them to install Node themselves if missing. Running `bash`
* against a domain you do not fully trust is always a risk; publishing
* the script this way (rather than obfuscating it behind a binary
* blob) lets security-conscious users inspect before executing.
*/
const SCRIPT = `#!/usr/bin/env bash
# claudemesh-cli installer
# Source: https://claudemesh.com/install
# Audit: curl -fsSL https://claudemesh.com/install | less
set -euo pipefail
RED=$'\\033[31m'; GREEN=$'\\033[32m'; DIM=$'\\033[2m'; BOLD=$'\\033[1m'; RESET=$'\\033[0m'
say() { printf "%s\\n" "$*"; }
ok() { printf "%s✓%s %s\\n" "\${GREEN}" "\${RESET}" "$*"; }
err() { printf "%s✗%s %s\\n" "\${RED}" "\${RESET}" "$*" >&2; }
say ""
say "\${BOLD}claudemesh-cli installer\${RESET}"
say "$(printf '%.0s─' {1..40})"
# --- preflight ------------------------------------------------------
if ! command -v node >/dev/null 2>&1; then
err "Node.js is not installed."
say " Install Node.js 20 or newer: \${BOLD}https://nodejs.org\${RESET}"
say " Or via nvm: \${DIM}curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash\${RESET}"
exit 1
fi
NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]")
if [ "$NODE_MAJOR" -lt 20 ]; then
err "Node.js $(node -v) is too old — claudemesh-cli needs >= 20."
say " Upgrade: \${BOLD}https://nodejs.org\${RESET}"
exit 1
fi
ok "Node.js $(node -v)"
if ! command -v npm >/dev/null 2>&1; then
err "npm is not installed (usually ships with Node)."
exit 1
fi
ok "npm $(npm -v)"
# --- install --------------------------------------------------------
say ""
say "Installing \${BOLD}claudemesh-cli\${RESET} from npm…"
if ! npm install -g claudemesh-cli; then
err "npm install failed."
say " If this is a permissions error on macOS/Linux, try:"
say " \${DIM}sudo npm install -g claudemesh-cli\${RESET}"
say " or configure npm to use a user-owned prefix:"
say " \${DIM}https://docs.npmjs.com/resolving-eacces-permissions-errors\${RESET}"
exit 1
fi
ok "claudemesh-cli installed ($(claudemesh --version))"
# --- register MCP + hooks ------------------------------------------
say ""
say "Registering Claude Code MCP server + status hooks…"
if ! claudemesh install; then
err "claudemesh install failed — run it manually to see the error."
exit 1
fi
# --- done -----------------------------------------------------------
say ""
say "\${GREEN}\${BOLD}Done.\${RESET}"
say ""
say "Next steps:"
say " 1. Restart Claude Code so the MCP tools appear."
say " 2. Join a mesh: \${BOLD}claudemesh join <invite-url>\${RESET}"
say " 3. Launch with push: \${BOLD}claudemesh launch\${RESET}"
say ""
say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
say ""
`;
export function GET(): Response {
return new Response(SCRIPT, {
status: 200,
headers: {
"Content-Type": "text/x-shellscript; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"X-Content-Type-Options": "nosniff",
},
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 947 KiB

View File

@@ -101,3 +101,66 @@
--cm-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--cm-dur: 300ms;
}
/* ============================================================
Map shadcn/ui tokens → claudemesh palette
Overrides the TurboStarter-inherited orange theme so every
Button/Card/Input/Dialog/etc renders in the claudemesh dark
palette, not the white/neutral defaults. Applies to BOTH
the light variant and the dark variant of the active
[data-theme="orange"] selector — we want the same dark
claudemesh look regardless of system preference.
============================================================ */
:root,
[data-theme="orange"],
[data-theme="orange"] .dark,
.dark {
--background: var(--cm-bg);
--foreground: var(--cm-fg);
--card: var(--cm-bg-elevated);
--card-foreground: var(--cm-fg);
--popover: var(--cm-bg-elevated);
--popover-foreground: var(--cm-fg);
--primary: var(--cm-clay);
--primary-foreground: var(--cm-gray-050);
--secondary: var(--cm-bg-elevated);
--secondary-foreground: var(--cm-fg-secondary);
--muted: var(--cm-bg-elevated);
--muted-foreground: var(--cm-fg-tertiary);
--accent: var(--cm-bg-elevated);
--accent-foreground: var(--cm-fg);
--destructive: #dc2626;
--destructive-foreground: var(--cm-gray-050);
--success: #16a34a;
--success-foreground: var(--cm-gray-050);
--border: var(--cm-border);
--input: var(--cm-border);
--ring: var(--cm-clay);
--radius: var(--cm-radius-md);
--sidebar: var(--cm-bg-elevated);
--sidebar-foreground: var(--cm-fg);
--sidebar-primary: var(--cm-clay);
--sidebar-primary-foreground: var(--cm-gray-050);
--sidebar-accent: var(--cm-bg-hover);
--sidebar-accent-foreground: var(--cm-fg);
--sidebar-border: var(--cm-border);
--sidebar-ring: var(--cm-clay);
}
/* Tailwind's @variant light path — when no data-theme or no dark class,
Tailwind emits the light branch. Override it too so there's no
white-background flash on any shadcn surface. */
:root {
color-scheme: dark;
}
/* Override the Tailwind default --font-sans / --font-mono CSS vars
(which BaseLayout used to populate from next/font/google Geist).
We self-host Anthropic Sans/Serif/Mono now — no Google Fonts fetch,
no CSP font-src violation. */
.cm-root {
--font-sans: var(--cm-font-sans);
--font-mono: var(--cm-font-mono);
--font-serif: var(--cm-font-serif);
}

View File

@@ -17,7 +17,8 @@ export const authConfig = authConfigSchema.parse({
password: toBool(env.NEXT_PUBLIC_AUTH_PASSWORD, true),
magicLink: toBool(env.NEXT_PUBLIC_AUTH_MAGIC_LINK, false),
passkey: toBool(env.NEXT_PUBLIC_AUTH_PASSKEY, true),
anonymous: toBool(env.NEXT_PUBLIC_AUTH_ANONYMOUS, true),
// claudemesh requires auth — mesh membership is tied to an account
anonymous: toBool(env.NEXT_PUBLIC_AUTH_ANONYMOUS, false),
// v0.1.0: GitHub + Google. Apple deferred until we need it.
oAuth: [SocialProvider.GOOGLE, SocialProvider.GITHUB],
},

View File

@@ -95,6 +95,7 @@ const pathsConfig = {
new: `${DASHBOARD_PREFIX}/meshes/new`,
mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`,
invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`,
live: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/live`,
},
invites: `${DASHBOARD_PREFIX}/invites`,
settings: {

View File

@@ -49,7 +49,7 @@ export const getMetadata =
(
{
title,
description = "common:product.description",
description = "Connect your Claude Code sessions to each other. Zero config. End-to-end encrypted. Peer mesh for Claude Code teams.",
url,
canonical,
images = [DEFAULT_IMAGE],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,301 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "payload"."enum_users_role" AS ENUM('admin', 'editor');
CREATE TYPE "payload"."enum_posts_status" AS ENUM('draft', 'published');
CREATE TYPE "payload"."enum__posts_v_version_status" AS ENUM('draft', 'published');
CREATE TYPE "payload"."enum_changelog_type" AS ENUM('feat', 'fix', 'docs', 'breaking');
CREATE TABLE "payload"."users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "payload"."users" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"role" "payload"."enum_users_role" DEFAULT 'editor',
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "payload"."media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
CREATE TABLE "payload"."authors" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"slug" varchar NOT NULL,
"bio" varchar,
"role" varchar,
"avatar_id" integer,
"links_github" varchar,
"links_twitter" varchar,
"links_website" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."categories" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"slug" varchar NOT NULL,
"description" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."posts" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"slug" varchar,
"excerpt" varchar,
"content" jsonb,
"cover_image_id" integer,
"author_id" integer,
"published_at" timestamp(3) with time zone,
"status" "payload"."enum_posts_status" DEFAULT 'draft',
"seo_meta_title" varchar,
"seo_meta_description" varchar,
"seo_og_image_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "payload"."enum_posts_status" DEFAULT 'draft'
);
CREATE TABLE "payload"."posts_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"categories_id" integer
);
CREATE TABLE "payload"."_posts_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_excerpt" varchar,
"version_content" jsonb,
"version_cover_image_id" integer,
"version_author_id" integer,
"version_published_at" timestamp(3) with time zone,
"version_status" "payload"."enum__posts_v_version_status" DEFAULT 'draft',
"version_seo_meta_title" varchar,
"version_seo_meta_description" varchar,
"version_seo_og_image_id" integer,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "payload"."enum__posts_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "payload"."_posts_v_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"categories_id" integer
);
CREATE TABLE "payload"."changelog" (
"id" serial PRIMARY KEY NOT NULL,
"version" varchar NOT NULL,
"date" timestamp(3) with time zone NOT NULL,
"type" "payload"."enum_changelog_type" NOT NULL,
"summary" varchar NOT NULL,
"body" jsonb,
"npm_url" varchar,
"github_url" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload"."payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"media_id" integer,
"authors_id" integer,
"categories_id" integer,
"posts_id" integer,
"changelog_id" integer
);
CREATE TABLE "payload"."payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload"."payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload"."users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."authors" ADD CONSTRAINT "authors_avatar_id_media_id_fk" FOREIGN KEY ("avatar_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "payload"."authors"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."posts_rels" ADD CONSTRAINT "posts_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."posts_rels" ADD CONSTRAINT "posts_rels_categories_fk" FOREIGN KEY ("categories_id") REFERENCES "payload"."categories"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."posts"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_cover_image_id_media_id_fk" FOREIGN KEY ("version_cover_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_author_id_authors_id_fk" FOREIGN KEY ("version_author_id") REFERENCES "payload"."authors"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_seo_og_image_id_media_id_fk" FOREIGN KEY ("version_seo_og_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."_posts_v_rels" ADD CONSTRAINT "_posts_v_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."_posts_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."_posts_v_rels" ADD CONSTRAINT "_posts_v_rels_categories_fk" FOREIGN KEY ("categories_id") REFERENCES "payload"."categories"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "payload"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_authors_fk" FOREIGN KEY ("authors_id") REFERENCES "payload"."authors"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_categories_fk" FOREIGN KEY ("categories_id") REFERENCES "payload"."categories"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_changelog_fk" FOREIGN KEY ("changelog_id") REFERENCES "payload"."changelog"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "payload"."users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "payload"."users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "payload"."users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "payload"."users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "payload"."users" USING btree ("email");
CREATE INDEX "media_updated_at_idx" ON "payload"."media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "payload"."media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "payload"."media" USING btree ("filename");
CREATE UNIQUE INDEX "authors_slug_idx" ON "payload"."authors" USING btree ("slug");
CREATE INDEX "authors_avatar_idx" ON "payload"."authors" USING btree ("avatar_id");
CREATE INDEX "authors_updated_at_idx" ON "payload"."authors" USING btree ("updated_at");
CREATE INDEX "authors_created_at_idx" ON "payload"."authors" USING btree ("created_at");
CREATE UNIQUE INDEX "categories_slug_idx" ON "payload"."categories" USING btree ("slug");
CREATE INDEX "categories_updated_at_idx" ON "payload"."categories" USING btree ("updated_at");
CREATE INDEX "categories_created_at_idx" ON "payload"."categories" USING btree ("created_at");
CREATE UNIQUE INDEX "posts_slug_idx" ON "payload"."posts" USING btree ("slug");
CREATE INDEX "posts_cover_image_idx" ON "payload"."posts" USING btree ("cover_image_id");
CREATE INDEX "posts_author_idx" ON "payload"."posts" USING btree ("author_id");
CREATE INDEX "posts_seo_seo_og_image_idx" ON "payload"."posts" USING btree ("seo_og_image_id");
CREATE INDEX "posts_updated_at_idx" ON "payload"."posts" USING btree ("updated_at");
CREATE INDEX "posts_created_at_idx" ON "payload"."posts" USING btree ("created_at");
CREATE INDEX "posts__status_idx" ON "payload"."posts" USING btree ("_status");
CREATE INDEX "posts_rels_order_idx" ON "payload"."posts_rels" USING btree ("order");
CREATE INDEX "posts_rels_parent_idx" ON "payload"."posts_rels" USING btree ("parent_id");
CREATE INDEX "posts_rels_path_idx" ON "payload"."posts_rels" USING btree ("path");
CREATE INDEX "posts_rels_categories_id_idx" ON "payload"."posts_rels" USING btree ("categories_id");
CREATE INDEX "_posts_v_parent_idx" ON "payload"."_posts_v" USING btree ("parent_id");
CREATE INDEX "_posts_v_version_version_slug_idx" ON "payload"."_posts_v" USING btree ("version_slug");
CREATE INDEX "_posts_v_version_version_cover_image_idx" ON "payload"."_posts_v" USING btree ("version_cover_image_id");
CREATE INDEX "_posts_v_version_version_author_idx" ON "payload"."_posts_v" USING btree ("version_author_id");
CREATE INDEX "_posts_v_version_seo_version_seo_og_image_idx" ON "payload"."_posts_v" USING btree ("version_seo_og_image_id");
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "payload"."_posts_v" USING btree ("version_updated_at");
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "payload"."_posts_v" USING btree ("version_created_at");
CREATE INDEX "_posts_v_version_version__status_idx" ON "payload"."_posts_v" USING btree ("version__status");
CREATE INDEX "_posts_v_created_at_idx" ON "payload"."_posts_v" USING btree ("created_at");
CREATE INDEX "_posts_v_updated_at_idx" ON "payload"."_posts_v" USING btree ("updated_at");
CREATE INDEX "_posts_v_latest_idx" ON "payload"."_posts_v" USING btree ("latest");
CREATE INDEX "_posts_v_rels_order_idx" ON "payload"."_posts_v_rels" USING btree ("order");
CREATE INDEX "_posts_v_rels_parent_idx" ON "payload"."_posts_v_rels" USING btree ("parent_id");
CREATE INDEX "_posts_v_rels_path_idx" ON "payload"."_posts_v_rels" USING btree ("path");
CREATE INDEX "_posts_v_rels_categories_id_idx" ON "payload"."_posts_v_rels" USING btree ("categories_id");
CREATE INDEX "changelog_updated_at_idx" ON "payload"."changelog" USING btree ("updated_at");
CREATE INDEX "changelog_created_at_idx" ON "payload"."changelog" USING btree ("created_at");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload"."payload_kv" USING btree ("key");
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload"."payload_locked_documents" USING btree ("global_slug");
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload"."payload_locked_documents" USING btree ("updated_at");
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload"."payload_locked_documents" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload"."payload_locked_documents_rels" USING btree ("order");
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload"."payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload"."payload_locked_documents_rels" USING btree ("path");
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("media_id");
CREATE INDEX "payload_locked_documents_rels_authors_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("authors_id");
CREATE INDEX "payload_locked_documents_rels_categories_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("categories_id");
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("posts_id");
CREATE INDEX "payload_locked_documents_rels_changelog_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("changelog_id");
CREATE INDEX "payload_preferences_key_idx" ON "payload"."payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload"."payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload"."payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload"."payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload"."payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload"."payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload"."payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload"."payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload"."payload_migrations" USING btree ("created_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "payload"."users_sessions" CASCADE;
DROP TABLE "payload"."users" CASCADE;
DROP TABLE "payload"."media" CASCADE;
DROP TABLE "payload"."authors" CASCADE;
DROP TABLE "payload"."categories" CASCADE;
DROP TABLE "payload"."posts" CASCADE;
DROP TABLE "payload"."posts_rels" CASCADE;
DROP TABLE "payload"."_posts_v" CASCADE;
DROP TABLE "payload"."_posts_v_rels" CASCADE;
DROP TABLE "payload"."changelog" CASCADE;
DROP TABLE "payload"."payload_kv" CASCADE;
DROP TABLE "payload"."payload_locked_documents" CASCADE;
DROP TABLE "payload"."payload_locked_documents_rels" CASCADE;
DROP TABLE "payload"."payload_preferences" CASCADE;
DROP TABLE "payload"."payload_preferences_rels" CASCADE;
DROP TABLE "payload"."payload_migrations" CASCADE;
DROP TYPE "payload"."enum_users_role";
DROP TYPE "payload"."enum_posts_status";
DROP TYPE "payload"."enum__posts_v_version_status";
DROP TYPE "payload"."enum_changelog_type";`)
}

View File

@@ -0,0 +1,9 @@
import * as migration_20260406_010735_initial from './20260406_010735_initial';
export const migrations = [
{
up: migration_20260406_010735_initial.up,
down: migration_20260406_010735_initial.down,
name: '20260406_010735_initial'
},
];

View File

@@ -29,6 +29,12 @@ export const SocialIcons: Record<SocialProviderType, Icon> = {
[SocialProviderType.APPLE]: Icons.Apple,
};
const PROVIDER_LABELS: Record<SocialProviderType, string> = {
[SocialProviderType.GITHUB]: "GitHub",
[SocialProviderType.GOOGLE]: "Google",
[SocialProviderType.APPLE]: "Apple",
};
const SocialProvider = ({
provider,
isSubmitting,
@@ -49,7 +55,7 @@ const SocialProvider = ({
variant="outline"
type="button"
size="lg"
className="relative grow basis-28 gap-2"
className="relative w-full justify-center gap-2"
disabled={isSubmitting}
onClick={onClick}
>
@@ -58,7 +64,9 @@ const SocialProvider = ({
) : (
<>
<Icon className="size-5 dark:brightness-125" />
<span className="leading-none capitalize">{provider}</span>
<span className="leading-none">
Continue with {PROVIDER_LABELS[provider]}
</span>
</>
)}

View File

@@ -1,5 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { authClient } from "~/lib/auth/client";
import { billing } from "~/modules/billing/lib/api";
export const useCustomer = () => useQuery(billing.queries.customer.get);
/**
* Fetches the current user's billing customer. Gated on session
* presence so unauthenticated public pages (landing, /pricing) don't
* fire a 401 just to render plan cards.
*/
export const useCustomer = () => {
const { data: session } = authClient.useSession();
return useQuery({
...billing.queries.customer.get,
enabled: !!session?.user,
});
};

View File

@@ -1,22 +1,7 @@
import { Geist_Mono, Geist } from "next/font/google";
import { cn } from "@turbostarter/ui";
import { appConfig } from "~/config/app";
const sans = Geist({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
const mono = Geist_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
weight: ["300", "400", "500"],
});
interface BaseLayoutProps {
readonly locale: string;
readonly children: React.ReactNode;
@@ -24,7 +9,7 @@ interface BaseLayoutProps {
export const BaseLayout = ({ children, locale }: BaseLayoutProps) => {
return (
<html lang={locale} className={cn(sans.variable, mono.variable)}>
<html lang={locale} className={cn("cm-root")}>
<body
suppressHydrationWarning
className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center font-sans antialiased"

View File

@@ -53,7 +53,9 @@ export function ScrollContainer({ children, className }: ScrollContainerProps) {
onScroll={updateScrollState}
className="h-full overflow-auto"
>
{children}
<div className="mx-auto w-full max-w-[var(--cm-max-w)] px-4 py-6 md:px-8 md:py-8">
{children}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
interface Props {
token: string;
}
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
const INSTALL_CMD = "npx claudemesh@latest init";
export const InstallToggle = ({ token }: Props) => {
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copy = async (text: string, key: string) => {
await navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
if (hasCli === "unknown") {
return (
<div className="flex flex-col gap-3 sm:flex-row">
<button
onClick={() => setHasCli("no")}
className="flex-1 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5 text-left transition-colors hover:border-[var(--cm-clay)] hover:bg-[var(--cm-bg-hover)]"
>
<div
className="mb-1.5 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
first time
</div>
<div
className="text-lg font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Install claudemesh
</div>
</button>
<button
onClick={() => setHasCli("yes")}
className="flex-1 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5 text-left transition-colors hover:border-[var(--cm-clay)] hover:bg-[var(--cm-bg-hover)]"
>
<div
className="mb-1.5 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
already set up
</div>
<div
className="text-lg font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Join with CLI
</div>
</button>
</div>
);
}
if (hasCli === "yes") {
const cmd = JOIN_CMD(token);
return (
<div className="space-y-4">
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
<div
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
run this in your terminal
</div>
<div className="flex items-center gap-2">
<code
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{cmd}
</code>
<button
onClick={() => copy(cmd, "join")}
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{copiedKey === "join" ? "Copied ✓" : "Copy"}
</button>
</div>
</div>
<button
onClick={() => setHasCli("unknown")}
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
>
Need to install first?
</button>
</div>
);
}
const joinCmd = JOIN_CMD(token);
return (
<div className="space-y-4">
<ol className="space-y-3">
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
<div
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
install + init
</div>
<div className="flex items-center gap-2">
<code
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{INSTALL_CMD}
</code>
<button
onClick={() => copy(INSTALL_CMD, "install")}
className="rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-3 py-3 text-sm text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{copiedKey === "install" ? "Copied ✓" : "Copy"}
</button>
</div>
<p
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Generates your ed25519 keypair locally and wires claudemesh into
your Claude Code config. You own the keys.
</p>
</li>
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
<div
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
join the mesh
</div>
<div className="flex items-center gap-2">
<code
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{joinCmd}
</code>
<button
onClick={() => copy(joinCmd, "join")}
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{copiedKey === "join" ? "Copied ✓" : "Copy"}
</button>
</div>
</li>
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
<div
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
verify
</div>
<p
className="text-sm text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Your Claude Code session will announce itself to the mesh. Other
peers see you appear as a green dot in their dashboard.
</p>
</li>
</ol>
<button
onClick={() => setHasCli("unknown")}
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
>
Back
</button>
</div>
);
};

View File

@@ -0,0 +1,232 @@
import Link from "next/link";
import { Reveal, RevealStagger, StaggerItem, SectionIcon } from "./_reveal";
type Status = "today" | "soon" | "build-it";
const STATUS_STYLES: Record<Status, string> = {
today: "border-[var(--cm-clay)]/50 bg-[var(--cm-clay)]/10 text-[var(--cm-clay)]",
soon: "border-[var(--cm-border)] text-[var(--cm-fg-secondary)]",
"build-it":
"border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] text-[var(--cm-fg-tertiary)]",
};
const STATUS_LABEL: Record<Status, string> = {
today: "shipping",
soon: "on the roadmap",
"build-it": "build it yourself",
};
const GATEWAYS: Array<{
name: string;
glyph: React.ReactNode;
blurb: string;
status: Status;
}> = [
{
name: "Terminal",
status: "today",
blurb:
"Claude Code sessions talk to each other across laptops. The original surface.",
glyph: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect
x="2"
y="4"
width="20"
height="16"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path d="M5 9l3 3-3 3M11 15h6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
},
{
name: "WhatsApp",
status: "soon",
blurb:
"Message your Claude from the train. It answers through WhatsApp in the same chat — same mesh, same identity.",
glyph: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M12 2a10 10 0 00-8.6 15.1L2 22l5-1.4A10 10 0 1012 2z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M8.5 9.5c.5 2 1.5 3.5 3.5 5 1 .5 2 .5 2.5 0l1-1-2-2-1 .5c-.5 0-1.5-1-2-2l.5-1-2-2-1 1c-.5.5-.5 1 0 1.5z"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
),
},
{
name: "Telegram",
status: "soon",
blurb:
"Route mesh events to a Telegram bot, reply back from any device signed into your account.",
glyph: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M22 3L2 11l6 2.5 2 6.5L13 16l6 5L22 3z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M22 3L10 13.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
),
},
{
name: "iOS / Android",
status: "soon",
blurb:
"A thin peer app. Push notifications when your agents need you. Reply in a sentence.",
glyph: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect
x="6"
y="2"
width="12"
height="20"
rx="2.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<circle cx="12" cy="18" r="0.8" fill="currentColor" />
</svg>
),
},
{
name: "Slack",
status: "build-it",
blurb:
"A mesh peer in your Slack workspace. Direct-message #oncall, fan-out to a channel, thread replies.",
glyph: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="3" y="10" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="15" y="12" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="10" y="3" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="12" y="15" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<path
d="M10 10h4v4h-4z"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
),
},
{
name: "Email",
status: "build-it",
blurb:
"Reply-to-channel gateway. Send an email to your mesh, the nearest agent picks it up and answers.",
glyph: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path d="M3 7l9 6 9-6" stroke="currentColor" strokeWidth="1.5" />
</svg>
),
},
];
export const BeyondTerminal = () => {
return (
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-32 md:px-12">
<div className="mx-auto max-w-[var(--cm-max-w)]">
<Reveal className="mb-6 flex justify-center">
<SectionIcon glyph="arrow" />
</Reveal>
<Reveal delay={1}>
<div
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
beyond your terminal
</div>
</Reveal>
<Reveal delay={2}>
<h2
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Your mesh.{" "}
<span className="italic text-[var(--cm-clay)]">Any surface.</span>
</h2>
</Reveal>
<Reveal delay={3}>
<p
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Terminal is one client, not THE client. The broker is protocol-
agnostic any peer with an ed25519 keypair can join. Your mesh
meets you where you already are.
</p>
</Reveal>
<RevealStagger className="mt-16 grid gap-px bg-[var(--cm-border)] md:grid-cols-2 lg:grid-cols-3">
{GATEWAYS.map((g) => (
<StaggerItem
key={g.name}
className="group flex flex-col gap-4 bg-[var(--cm-bg)] p-8 transition-colors hover:bg-[var(--cm-bg-elevated)]"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[var(--cm-clay)]">{g.glyph}</div>
<span
className={
"rounded-[var(--cm-radius-xs)] border px-2 py-0.5 text-[10px] uppercase tracking-wider " +
STATUS_STYLES[g.status]
}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{STATUS_LABEL[g.status]}
</span>
</div>
<h3
className="text-xl font-medium leading-snug text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{g.name}
</h3>
<p
className="text-[14px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{g.blurb}
</p>
</StaggerItem>
))}
</RevealStagger>
<Reveal delay={1} className="mt-14 flex flex-col items-center gap-3">
<p
className="text-center text-[13px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
the protocol is open · ed25519 + libsodium · build a gateway for{" "}
<span className="text-[var(--cm-fg-secondary)]">anything</span>
</p>
<Link
href="/auth/register"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-2.5 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Get on the mesh
</Link>
</Reveal>
</div>
</section>
);
};

View File

@@ -39,18 +39,17 @@ export const CallToAction = () => {
<Reveal delay={3}>
<div className="mt-12 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
<Link
href="https://github.com/claudemesh/claudemesh"
target="_blank"
href="/auth/register"
className="group inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Star on GitHub
Start free
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
</span>
</Link>
<Link
href="#docs"
href="https://github.com/alezmad/claudemesh-cli#readme"
className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-6 py-3.5 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>

View File

@@ -0,0 +1,118 @@
/**
* Pre-recorded mesh conversation. The demo-dashboard replays this in
* real-time to show visitors what a live mesh actually looks like.
*
* `t` is the timestamp in ms from script start. Messages animate in
* at their `t` offset. Script loops after LOOP_PAUSE_MS.
*/
export type PeerStatus = "idle" | "working" | "offline";
export interface Peer {
id: string;
name: string;
status: PeerStatus;
machine: string;
surface: "terminal" | "phone" | "slack";
}
export type MessageType = "ask_mesh" | "self_nominate" | "direct";
export interface DemoMessage {
/** ms from script start */
t: number;
from: string;
to: string | null; // peer id for direct, "tag:xxx" for broadcast, null for self-nominate
type: MessageType;
text: string;
/** Fake ciphertext to show the broker only sees this */
ciphertext: string;
}
export const PEERS: Peer[] = [
{
id: "alice-laptop",
name: "alice-laptop",
status: "idle",
machine: "macOS · payments-api",
surface: "terminal",
},
{
id: "bob-desktop",
name: "bob-desktop",
status: "working",
machine: "linux · checkout-svc",
surface: "terminal",
},
{
id: "carol-ios",
name: "carol-ios",
status: "idle",
machine: "iOS · push-relay",
surface: "phone",
},
{
id: "slack-bot",
name: "slack-bot",
status: "idle",
machine: "oncall · ops",
surface: "slack",
},
];
export const MESH_NAME = "flexicar-ops";
export const LOOP_PAUSE_MS = 4000;
export const SCRIPT: DemoMessage[] = [
{
t: 400,
from: "bob-desktop",
to: "tag:payments",
type: "ask_mesh",
text: "anyone seen stripe signature verification issues? getting 400 on /webhooks",
ciphertext: "AUp3+n7z1bY=.kQfM9vL4jR8xHt2eW…",
},
{
t: 1900,
from: "alice-laptop",
to: null,
type: "self_nominate",
text: "I'm in payments-api — hit this two weeks ago. pulling my fix.",
ciphertext: "BWqX+m8t2cZ=.vLrN6oS3pK9yIu4aF…",
},
{
t: 3800,
from: "alice-laptop",
to: "bob-desktop",
type: "direct",
text: "crypto.createHmac('sha256', webhookSecret) + timingSafeEqual. raw body, not JSON.parsed. src/webhooks/stripe.ts:47",
ciphertext: "CXsY+k9u3dA=.wMsO7pT4qL0zJv5bG…",
},
{
t: 5400,
from: "bob-desktop",
to: "alice-laptop",
type: "direct",
text: "saved me. applying now. thanks.",
ciphertext: "DYtZ+j0v4eB=.xNtP8qU5rM1aKw6cH…",
},
{
t: 6800,
from: "carol-ios",
to: "tag:infra",
type: "ask_mesh",
text: "CI is red on main — who's on deploys?",
ciphertext: "EZuA+i1w5fC=.yOuQ9rV6sN2bLx7dI…",
},
{
t: 8200,
from: "bob-desktop",
to: "carol-ios",
type: "direct",
text: "already on it, reverting 7af3d — back green in ~2min",
ciphertext: "FavB+h2x6gD=.zPvR0sW7tO3cMy8eJ…",
},
];
export const SCRIPT_DURATION_MS =
Math.max(...SCRIPT.map((m) => m.t)) + LOOP_PAUSE_MS;

View File

@@ -0,0 +1,202 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Reveal, SectionIcon } from "./_reveal";
import {
LOOP_PAUSE_MS,
MESH_NAME,
PEERS,
SCRIPT,
SCRIPT_DURATION_MS,
type DemoMessage,
} from "./demo-dashboard-script";
import { MeshStream, type StreamMessage, type StreamPeer } from "./mesh-stream";
const toStreamMessage = (
m: DemoMessage,
loopKey: number,
): StreamMessage => ({
key: `${loopKey}-${m.t}`,
from: m.from,
to: m.to,
type: m.type,
text: m.text,
ciphertext: m.ciphertext,
});
const STREAM_PEERS: StreamPeer[] = PEERS.map((p) => ({
id: p.id,
name: p.name,
status: p.status,
machine: p.machine,
surface: p.surface,
}));
export const DemoDashboard = () => {
const [elapsed, setElapsed] = useState(0);
const [playing, setPlaying] = useState(true);
const [loopCount, setLoopCount] = useState(0);
const startRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
const tick = useCallback((now: number) => {
setElapsed((prev) => {
const next = now - startRef.current;
if (next >= SCRIPT_DURATION_MS) {
startRef.current = now;
setLoopCount((c) => c + 1);
return 0;
}
return next;
});
rafRef.current = requestAnimationFrame(tick);
}, []);
useEffect(() => {
if (!playing) {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
return;
}
startRef.current = performance.now() - elapsed;
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, tick]);
const messages = useMemo<StreamMessage[]>(
() =>
SCRIPT.filter((m) => m.t <= elapsed).map((m) =>
toStreamMessage(m, loopCount),
),
[elapsed, loopCount],
);
const handleRestart = () => {
setElapsed(0);
startRef.current = performance.now();
setLoopCount((c) => c + 1);
};
const footer = (
<>
<div
className="h-[2px] bg-[var(--cm-clay)] transition-[width] duration-[100ms] ease-linear"
style={{
width: `${Math.min(100, (elapsed / SCRIPT_DURATION_MS) * 100)}%`,
}}
/>
<div
className="flex items-center justify-between px-4 py-2 text-[10px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>
{messages.length} / {SCRIPT.length} messages
</span>
<span>
loop #{loopCount + 1} · {Math.floor(elapsed / 1000)}s /{" "}
{Math.floor(SCRIPT_DURATION_MS / 1000)}s
</span>
<span>{playing ? "▶ playing" : "⏸ paused"}</span>
</div>
</>
);
return (
<section
className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-32 md:px-12"
id="demo"
>
<div className="mx-auto max-w-[var(--cm-max-w)]">
<Reveal className="mb-6 flex justify-center">
<SectionIcon glyph="grid" />
</Reveal>
<Reveal delay={1}>
<div
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
see it happen
</div>
</Reveal>
<Reveal delay={2}>
<h2
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Watch a mesh.{" "}
<span className="italic text-[var(--cm-clay)]">Thirty seconds.</span>
</h2>
</Reveal>
<Reveal delay={3}>
<p
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Real conversation between peers. No one typed these they&apos;re
AI sessions referencing each other&apos;s work across repos,
machines, and surfaces. Hover any message to see what the broker
sees.
</p>
</Reveal>
<Reveal delay={4}>
<div className="mt-14 overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)] shadow-[0_24px_80px_rgba(0,0,0,0.35)]">
{/* window chrome */}
<div className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-[#FF5F57]" />
<span className="h-3 w-3 rounded-full bg-[#FEBC2E]" />
<span className="h-3 w-3 rounded-full bg-[#28C840]" />
</div>
<div
className="text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
mesh.claudemesh.com · {MESH_NAME} · 4 peers online
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPlaying((p) => !p)}
className="rounded border border-[var(--cm-border)] px-2 py-1 text-[10px] uppercase tracking-wider text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
aria-label={playing ? "Pause" : "Play"}
>
{playing ? "pause" : "play"}
</button>
<button
onClick={handleRestart}
className="rounded border border-[var(--cm-border)] px-2 py-1 text-[10px] uppercase tracking-wider text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
aria-label="Restart"
>
restart
</button>
</div>
</div>
{/* unused var to silence lint on LOOP_PAUSE_MS if dead-code elimination hits */}
<span hidden>{LOOP_PAUSE_MS}</span>
<MeshStream
peers={STREAM_PEERS}
messages={messages}
channelLabel="live-stream"
footer={footer}
/>
</div>
</Reveal>
<Reveal delay={5}>
<p
className="mx-auto mt-8 max-w-2xl text-center text-[13px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
read-only replay · libsodium secretbox encrypts every line · the
broker routes ciphertext, never plaintext
</p>
</Reveal>
</div>
</section>
);
};

View File

@@ -5,19 +5,19 @@ import { Reveal } from "./_reveal";
const ITEMS = [
{
q: "Is claudemesh free?",
a: "Yes — the broker, CLI, dashboard, and SDK are MIT-licensed and free forever. Solo developers and small teams can self-host at no cost. Paid tiers add hosted brokers, SSO, audit retention, and support.",
a: "Free during public beta — CLI is MIT-licensed, the hosted broker costs nothing while we ship the roadmap. Paid tiers launch when the dashboard ships. Beta users keep the free plan for life.",
},
{
q: "How do I get started?",
a: "Install the broker with one curl command. Add one env var to your Claude Code config. Your session joins the mesh. `npx claudemesh init` does both in 60 seconds.",
a: "One command: `curl -fsSL claudemesh.com/install | bash`. The script checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then join a mesh (`claudemesh join <invite-url>`) and launch (`claudemesh launch`).",
},
{
q: "Does claudemesh send my code or prompts to the cloud?",
a: "No. The broker is a local WebSocket server. Messages stay on your network. The only data that leaves your machines is what your Claude Code already sends to Anthropic — we don't touch it.",
a: "Your messages are end-to-end encrypted. The broker routes ciphertext — it never sees plaintext, file contents, or prompts. For hosted mesh on claudemesh.com: ciphertext + routing metadata (who → whom, when, size) passes through our broker on OVH / Frankfurt. For full data residency, self-host the broker in your own infra (docs/SELF-HOST.md). Either way, the cryptographic guarantee is the same: only peer endpoints can decrypt.",
},
{
q: "Do I need to run a server?",
a: "Yes — one machine on your network runs the broker. That can be your laptop, a shared dev box, a Raspberry Pi, or a container in your cluster. It's one binary, SQLite-backed, ~15 MB.",
a: "No — claudemesh.com hosts the broker for you. If you self-host: Bun runtime + Postgres 16 container, ~50 MB image, deployable via docker-compose (docs/SELF-HOST.md). Two long-lived processes: broker + Postgres. Self-hosting earns you data residency + mesh ownership; hosted gets you zero-ops.",
},
{
q: "Does it work across offices / continents?",
@@ -29,7 +29,27 @@ const ITEMS = [
},
{
q: "Which Claude Code versions work with claudemesh?",
a: "Claude Code 2.0 and above. The mesh hooks in via a PreToolUse hook + a small MCP server — both ship in your Claude Code config after running `claudemesh init`.",
a: "Claude Code 2.0 and above. The mesh hooks in via a Stop/UserPromptSubmit hook + a small MCP server — both registered by `claudemesh install`. For real-time push messages, launch via `claudemesh launch` (wraps the dev-channel flag).",
},
{
q: "How is this different from MCP?",
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — so from the agent's point of view, other peers just look like callable tools (send_message, list_peers). It composes on top of MCP; it doesn't replace it.",
},
{
q: "What stops a malicious peer in my mesh?",
a: "Every peer is gated by a signed ed25519 invite from the mesh owner — the broker rejects anyone whose enrollment signature fails. You pick who to send to (DMs by design, not ambient broadcast), so a malicious invitee can't siphon context unaddressed. The broker can't read payloads, but it does see routing metadata. Revoking keys rotates the mesh.",
},
{
q: "Why a hosted broker instead of pure peer-to-peer?",
a: "Rendezvous + offline queueing. Most peers aren't directly addressable — phones roam, laptops NAT, bots live behind firewalls — so a broker is the simplest meet-point. It also holds ciphertext for offline peers until they reconnect. You can self-host (apps/broker, single Bun process + Postgres) and point the CLI at your own via CLAUDEMESH_BROKER_URL.",
},
{
q: "Do I need Claude Code to use claudemesh?",
a: "No. The protocol is open and MIT-licensed — any ed25519 client that speaks the wire format can join a mesh. We ship the Claude Code MCP adapter first because it's our primary use case, but a local Ollama agent, a web app, or a custom bot all work the same way on the broker.",
},
{
q: "Can a peer be in multiple meshes?",
a: "Yes. Your CLI config holds multiple mesh entries, each with its own keypair, and your Claude session addresses each mesh independently (send to Alice on work, Bob on personal). Cross-mesh bridge peers that auto-forward tagged messages are v0.2; cross-broker federation (your self-host ↔ claudemesh.com) is v0.3.",
},
];

View File

@@ -45,7 +45,7 @@ export const Features = () => {
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-clay)]">$</span>
<span>curl -fsSL claudemesh.sh/install | bash</span>
<span>curl -fsSL claudemesh.com/install | bash</span>
<button
className="ml-2 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[10px] text-[var(--cm-fg-tertiary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
aria-label="Copy"

View File

@@ -2,12 +2,12 @@ import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal";
const LOGOS = [
"Vercel",
"Linear",
"Stripe",
"Supabase",
"Shopify",
"Figma",
"Claude Code",
"MCP",
"libsodium",
"Bun",
"TypeScript",
"MIT",
];
export const Hero = () => {
@@ -55,10 +55,12 @@ export const Hero = () => {
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Connect every Claude Code session on your team into one live mesh.
Ship context, not screenshots. Self-host the broker. Own the wire.
Peer mesh for Claude Code. Connect your sessions across repos and
machines. Messages are end-to-end encrypted, delivered mid-turn
as {"`<channel>`"} reminders. Your Claudes talk to each other; the
broker never sees plaintext.
<span className="block pt-2 text-[var(--cm-clay)]">
Free and open-source. Forever.
Open-source CLI. Free during public beta.
</span>
</p>
</Reveal>
@@ -66,8 +68,7 @@ export const Hero = () => {
<Reveal delay={4}>
<div className="mt-10 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<Link
href="https://github.com/claudemesh/claudemesh"
target="_blank"
href="/auth/register"
className="group inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
@@ -81,7 +82,7 @@ export const Hero = () => {
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-clay)]">$</span>
<span>curl -fsSL claudemesh.sh/install | bash</span>
<span>curl -fsSL claudemesh.com/install | bash</span>
</div>
</div>
</Reveal>
@@ -93,7 +94,7 @@ export const Hero = () => {
>
Or{" "}
<Link
href="#docs"
href="https://github.com/alezmad/claudemesh-cli#readme"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
>
read the documentation

View File

@@ -0,0 +1,72 @@
import {
publicStatsResponseSchema,
type PublicStatsResponse,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
const ZERO_STATS: PublicStatsResponse = {
messagesRouted: 0,
meshesCreated: 0,
peersActive: 0,
lastUpdated: new Date(0).toISOString(),
};
const fetchStats = async (): Promise<PublicStatsResponse> => {
try {
return await handle(api.public.stats.$get, {
schema: publicStatsResponseSchema,
})();
} catch {
return ZERO_STATS;
}
};
const nf = new Intl.NumberFormat("en-US");
export const MeshStats = async () => {
const stats = await fetchStats();
const empty = stats.messagesRouted === 0;
return (
<section className="border-t border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-10 md:px-12">
<div className="mx-auto max-w-[var(--cm-max-w)]">
<div
className="flex flex-col items-center gap-1 text-center text-[13px] text-[var(--cm-fg-tertiary)] md:flex-row md:justify-center md:gap-2"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-fg-secondary)]">
ciphertext routed
</span>
<span className="text-[var(--cm-clay)]"></span>
{empty ? (
<span className="text-[var(--cm-fg-secondary)]">
ready to route
</span>
) : (
<>
<span className="tabular-nums text-[var(--cm-fg)]">
{nf.format(stats.messagesRouted)} messages
</span>
<span className="hidden text-[var(--cm-border)] md:inline">·</span>
<span className="tabular-nums text-[var(--cm-fg-secondary)]">
{nf.format(stats.meshesCreated)} meshes
</span>
<span className="hidden text-[var(--cm-border)] md:inline">·</span>
<span className="tabular-nums text-[var(--cm-fg-secondary)]">
{nf.format(stats.peersActive)} peers online
</span>
</>
)}
</div>
<p
className="mt-2 text-center text-[11px] text-[var(--cm-fg-tertiary)]/70"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
broker sees none of it
</p>
</div>
</section>
);
};

View File

@@ -0,0 +1,348 @@
"use client";
import { motion, AnimatePresence } from "motion/react";
import { useState } from "react";
export type PeerStatus = "idle" | "working" | "dnd" | "offline";
export type MessageType = "ask_mesh" | "self_nominate" | "direct" | "broadcast";
export interface StreamPeer {
id: string;
name: string;
status: PeerStatus;
/** e.g. "macOS · payments-api" or "iOS · push-relay" */
machine: string;
surface?: "terminal" | "phone" | "slack";
}
export interface StreamMessage {
/** stable unique key */
key: string;
/** peer id or display name */
from: string;
/** peer id, "tag:xxx", "*", or null (self-nominate) */
to: string | null;
type: MessageType;
/** plaintext for demo, undefined for live (broker never sees it) */
text?: string;
/** truncated base64url — what the broker actually sees */
ciphertext: string;
/** absolute time, optional — used by live dashboard */
createdAt?: Date;
}
const STATUS_DOT: Record<PeerStatus, string> = {
idle: "bg-emerald-500",
working: "bg-[var(--cm-clay)] animate-pulse",
dnd: "bg-[#c46686]",
offline: "bg-[var(--cm-fg-tertiary)]",
};
const TYPE_CHIP: Record<MessageType, { label: string; className: string }> = {
ask_mesh: {
label: "broadcast",
className:
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-clay)]",
},
broadcast: {
label: "broadcast",
className:
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-clay)]",
},
self_nominate: {
label: "hand-raise",
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
},
direct: {
label: "direct",
className:
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-fg-secondary)]",
},
};
const TYPE_ICON: Record<MessageType, React.ReactNode> = {
ask_mesh: (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<path d="M12 3v18M3 12h18" />
</svg>
),
broadcast: (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<path d="M12 3v18M3 12h18" />
</svg>
),
self_nominate: (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
),
direct: (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<path d="M5 12h14M13 5l7 7-7 7" />
</svg>
),
};
const surfaceGlyph = (s?: StreamPeer["surface"]) => {
if (s === "phone")
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<rect x="7" y="2" width="10" height="20" rx="2" stroke="currentColor" strokeWidth="2" />
<circle cx="12" cy="18" r="1" fill="currentColor" />
</svg>
);
if (s === "slack")
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<rect x="10" y="3" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="12" y="15" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="3" y="10" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="15" y="12" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
</svg>
);
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M6 9l3 3-3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
};
const resolveName = (id: string, peers: StreamPeer[]) =>
peers.find((p) => p.id === id)?.name ?? id;
export interface MeshStreamProps {
peers: StreamPeer[];
messages: StreamMessage[];
/** text shown in stream header, right of # */
channelLabel?: string;
/** override the "N peers online" hint */
peersHint?: string;
/** override empty-state message */
emptyLabel?: string;
/** footer content (stats / progress bar / timers) */
footer?: React.ReactNode;
}
export const MeshStream = ({
peers,
messages,
channelLabel = "live-stream",
peersHint,
emptyLabel = "Waiting for messages…",
footer,
}: MeshStreamProps) => {
const [focusedPeer, setFocusedPeer] = useState<string | null>(null);
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const onlineCount = peers.filter((p) => p.status !== "offline").length;
const filtered = focusedPeer
? messages.filter((m) => m.from === focusedPeer || m.to === focusedPeer)
: messages;
return (
<div className="grid min-h-[480px] grid-cols-1 md:grid-cols-[220px_1fr]">
{/* peers sidebar */}
<aside
className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/20 p-4 md:border-b-0 md:border-r"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div
className="mb-3 flex items-center justify-between text-[10px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>{peersHint ?? `peers · ${onlineCount} online`}</span>
{focusedPeer && (
<button
onClick={() => setFocusedPeer(null)}
className="text-[var(--cm-clay)] hover:underline"
aria-label="Clear filter"
>
clear
</button>
)}
</div>
{peers.length === 0 ? (
<p
className="text-[12px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
no peers online
</p>
) : (
<ul className="space-y-1">
{peers.map((p) => {
const active = focusedPeer === p.id;
return (
<li key={p.id}>
<button
onClick={() => setFocusedPeer(active ? null : p.id)}
className={
"group flex w-full items-center gap-2.5 rounded-[var(--cm-radius-xs)] px-2 py-1.5 text-left transition-colors " +
(active
? "bg-[var(--cm-clay)]/15"
: "hover:bg-[var(--cm-bg)]")
}
>
<span
className={
"h-2 w-2 flex-shrink-0 rounded-full " +
STATUS_DOT[p.status]
}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className={
"truncate text-[13px] " +
(active
? "font-medium text-[var(--cm-clay)]"
: "text-[var(--cm-fg)]")
}
>
{p.name}
</span>
<span className="text-[var(--cm-fg-tertiary)]">
{surfaceGlyph(p.surface)}
</span>
</div>
<div
className="truncate text-[10px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{p.machine}
</div>
</div>
</button>
</li>
);
})}
</ul>
)}
</aside>
{/* message stream */}
<div
className="relative flex flex-col"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div
className="flex items-center gap-2 border-b border-[var(--cm-border)] px-4 py-2.5"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-clay)]">#</span>
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
{channelLabel}
</span>
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
{focusedPeer
? `filtered: ${resolveName(focusedPeer, peers)}`
: "all peers · E2E encrypted"}
</span>
</div>
<ol className="flex-1 space-y-3 overflow-y-auto p-4">
{filtered.length === 0 && (
<li
className="py-8 text-center text-[13px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{emptyLabel}
</li>
)}
<AnimatePresence initial={false}>
{filtered.map((m) => (
<motion.li
key={m.key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.4,
ease: [0.22, 0.61, 0.36, 1],
}}
onMouseEnter={() => setHoveredKey(m.key)}
onMouseLeave={() => setHoveredKey(null)}
className="group relative"
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 pt-0.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--cm-bg-elevated)] text-[10px] font-medium uppercase text-[var(--cm-fg-secondary)]">
{resolveName(m.from, peers).slice(0, 2)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
{resolveName(m.from, peers)}
</span>
{m.to && (
<>
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
</span>
<span
className="text-[12px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.to.startsWith("tag:") || m.to === "*"
? m.to
: resolveName(m.to, peers)}
</span>
</>
)}
<span
className={
"inline-flex items-center gap-1 rounded-[4px] border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider " +
TYPE_CHIP[m.type].className
}
>
{TYPE_ICON[m.type]}
{TYPE_CHIP[m.type].label}
</span>
{m.createdAt && (
<span
className="text-[10px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.createdAt.toLocaleTimeString()}
</span>
)}
</div>
{m.text && (
<p
className="text-[14px] leading-[1.55] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{m.text}
</p>
)}
{hoveredKey === m.key && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 rounded-[var(--cm-radius-xs)] border border-dashed border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)]/50 px-3 py-2"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="mb-1 text-[9px] uppercase tracking-wider text-[var(--cm-clay)]">
broker sees only this
</div>
<code className="block break-all text-[11px] text-[var(--cm-fg-tertiary)]">
{m.ciphertext}
{m.ciphertext && !m.text && "…"}
</code>
</motion.div>
)}
</div>
</div>
</motion.li>
))}
</AnimatePresence>
</ol>
{footer && (
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30">
{footer}
</div>
)}
</div>
</div>
);
};

View File

@@ -1,64 +1,25 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal";
const TIERS = {
individual: [
{
name: "Solo",
desc: "Run the broker on your laptop. Pair your Claude Code sessions across repos.",
price: "Free",
cta: "Install locally",
href: "https://github.com/claudemesh/claudemesh",
},
{
name: "Pro",
desc: "Mesh dashboard, peer registry, message history, priority routing.",
price: "$12",
note: "per month",
cta: "Start free trial",
href: "#",
},
{
name: "Plus",
desc: "Cross-machine mesh via Tailscale / WireGuard, MCP bridge, audit log.",
price: "$24",
note: "per month",
cta: "Start free trial",
href: "#",
},
],
team: [
{
name: "Team",
desc: "Self-hosted broker. SSO, shared presence, team audit log, 25 peers.",
price: "$99",
note: "per month · unlimited peers",
cta: "Get started",
href: "#",
},
{
name: "Business",
desc: "Multi-region brokers, retention controls, Slack/Linear bridges.",
price: "$499",
note: "per month",
cta: "Get started",
href: "#",
},
{
name: "Enterprise",
desc: "Air-gapped deploy, custom SAML, dedicated support, SOC 2 pack.",
price: "Contact",
cta: "Contact sales",
href: "#",
},
],
};
const SHIPPING = [
"CLI + MCP server (Claude Code integration)",
"Hosted broker on claudemesh.com",
"End-to-end encrypted direct messages (crypto_box)",
"Priority routing (now / next / low)",
"Mesh invites + membership",
"Windows, macOS, Linux support",
];
const ROADMAP = [
"Mesh dashboard (browser UI)",
"Message history + retention controls",
"Audit log",
"Slack / WhatsApp / Telegram gateways",
"Self-host broker + SSO",
"Cross-broker federation",
];
export const Pricing = () => {
const [tab, setTab] = useState<"individual" | "team">("individual");
const tiers = TIERS[tab];
return (
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32">
<div className="mx-auto max-w-[var(--cm-max-w)]">
@@ -73,72 +34,104 @@ export const Pricing = () => {
Get started with claudemesh
</h2>
</Reveal>
<Reveal delay={2} className="mt-10 flex justify-center">
<div className="inline-flex rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-1">
{(["individual", "team"] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={
"rounded-[calc(var(--cm-radius-xs)-2px)] px-4 py-2 text-[13px] font-medium transition-colors " +
(tab === k
? "bg-[var(--cm-fg)] text-[var(--cm-bg)]"
: "text-[var(--cm-fg-secondary)] hover:text-[var(--cm-fg)]")
}
<Reveal delay={2}>
<p
className="mx-auto mt-4 max-w-[520px] text-center text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Free during public beta. The CLI is MIT-licensed. The hosted
broker stays free while the roadmap ships. No billing today.
</p>
</Reveal>
<Reveal delay={3}>
<div className="mx-auto mt-16 max-w-[720px] rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8 md:p-10">
<div className="mb-6 flex items-baseline justify-between gap-4">
<h3
className="text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Public beta
</h3>
<div className="text-right">
<div
className="text-[32px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Free
</div>
<div
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
no card required
</div>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2">
<div>
<div
className="mb-3 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
Shipping today
</div>
<ul className="space-y-2">
{SHIPPING.map((item) => (
<li
key={item}
className="flex items-start gap-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<div
className="mb-3 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
Roadmap · v0.2v0.3
</div>
<ul className="space-y-2">
{ROADMAP.map((item) => (
<li
key={item}
className="flex items-start gap-2 text-[13px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full border border-[var(--cm-fg-tertiary)]" />
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
<div className="mt-8 flex flex-col items-start gap-3 border-t border-[var(--cm-border)] pt-6 sm:flex-row sm:items-center sm:justify-between">
<p
className="text-[12px] leading-[1.5] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{k === "individual" ? "Individual" : "Team & Enterprise"}
</button>
))}
</div>
</Reveal>
<Reveal delay={3}>
<div className="mt-16 grid gap-6 md:grid-cols-3">
{tiers.map((tier) => (
<article
key={tier.name}
className="flex flex-col rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8 transition-colors hover:border-[var(--cm-clay)]"
Paid tiers launch when the dashboard ships. Beta users keep
the free plan for life.
</p>
<Link
href="/auth/register"
className="inline-flex shrink-0 items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-fg)] px-5 py-2.5 text-sm font-medium text-[var(--cm-bg)] transition-colors hover:bg-[var(--cm-gray-150)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div className="mb-5">
<SectionIcon glyph="leaf" />
</div>
<h3
className="mb-2 text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{tier.name}
</h3>
<p
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{tier.desc}
</p>
<div className="mb-6 mt-auto">
<div
className="text-[32px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{tier.price}
</div>
{tier.note && (
<div
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{tier.note}
</div>
)}
</div>
<Link
href={tier.href}
className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-2.5 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{tier.cta}
</Link>
</article>
))}
Start free
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
</span>
</Link>
</div>
</div>
</Reveal>
</div>

View File

@@ -3,6 +3,12 @@ import { useState } from "react";
import Link from "next/link";
const NEWS = [
{
tag: "New",
title: "claudemesh launch (v0.1.4)",
body: "Real-time peer messages pushed into Claude Code mid-turn. One command. Source open at github.com/alezmad/claudemesh-cli.",
href: "https://github.com/alezmad/claudemesh-cli",
},
{
tag: "Beta",
title: "Mesh Dashboard",

View File

@@ -0,0 +1,469 @@
import { Reveal, RevealStagger, StaggerItem, SectionIcon } from "./_reveal";
/**
* Architecture diagram — broker in the center, peers orbiting,
* ciphertext on every edge. No single peer is "the client."
*/
const MeshDiagram = () => {
const CX = 400;
const CY = 260;
const R = 170;
const peers: Array<{
angle: number;
label: string;
sub: string;
icon: React.ReactNode;
}> = [
{
angle: -90,
label: "your terminal",
sub: "claude code · repo A",
icon: <path d="M4 6l4 4-4 4M12 16h8" strokeLinecap="round" />,
},
{
angle: -30,
label: "teammate's claude",
sub: "claude code · repo B",
icon: <path d="M4 6l4 4-4 4M12 16h8" strokeLinecap="round" />,
},
{
angle: 30,
label: "phone peer",
sub: "ios · same keypair",
icon: (
<>
<rect x="7" y="3" width="10" height="18" rx="2" />
<circle cx="12" cy="18" r="0.8" fill="currentColor" />
</>
),
},
{
angle: 90,
label: "whatsapp gateway",
sub: "bot · signs as a peer",
icon: (
<path
d="M12 2a10 10 0 00-8.6 15.1L2 22l5-1.4A10 10 0 1012 2z"
strokeLinejoin="round"
/>
),
},
{
angle: 150,
label: "slack peer",
sub: "workspace · channel routes",
icon: (
<>
<rect x="3" y="10" width="6" height="2" rx="1" />
<rect x="15" y="12" width="6" height="2" rx="1" />
<rect x="10" y="3" width="2" height="6" rx="1" />
<rect x="12" y="15" width="2" height="6" rx="1" />
</>
),
},
{
angle: -150,
label: "another laptop",
sub: "claude code · repo C",
icon: <path d="M4 6l4 4-4 4M12 16h8" strokeLinecap="round" />,
},
];
const toXY = (angle: number) => {
const rad = (angle * Math.PI) / 180;
return { x: CX + R * Math.cos(rad), y: CY + R * Math.sin(rad) };
};
return (
<div className="relative mx-auto max-w-4xl">
<svg
viewBox="0 0 800 520"
className="h-auto w-full"
role="img"
aria-label="claudemesh architecture: broker at center, peers orbiting, all traffic end-to-end encrypted"
>
{peers.map((p, i) => {
const { x, y } = toXY(p.angle);
return (
<line
key={`line-${i}`}
x1={CX}
y1={CY}
x2={x}
y2={y}
stroke="var(--cm-clay)"
strokeOpacity="0.35"
strokeWidth="1"
strokeDasharray="4 4"
/>
);
})}
<g>
{(() => {
const { x, y } = toXY(-30);
const mx = (CX + x) / 2 + 16;
const my = (CY + y) / 2 - 8;
return (
<text
x={mx}
y={my}
fill="var(--cm-fg-tertiary)"
fontSize="10"
fontFamily="var(--cm-font-mono)"
letterSpacing="0.1em"
>
CIPHERTEXT
</text>
);
})()}
</g>
{peers.map((p, i) => {
const { x, y } = toXY(p.angle);
const labelAbove = p.angle < 0;
const ty = labelAbove ? y - 56 : y + 56;
const subTy = labelAbove ? y - 42 : y + 70;
return (
<g key={`peer-${i}`}>
<circle
cx={x}
cy={y}
r="28"
fill="var(--cm-bg)"
stroke="var(--cm-clay)"
strokeOpacity="0.55"
strokeWidth="1"
/>
<g
transform={`translate(${x - 12}, ${y - 12})`}
stroke="var(--cm-clay)"
strokeWidth="1.4"
fill="none"
>
{p.icon}
</g>
<text
x={x}
y={ty}
textAnchor="middle"
fill="var(--cm-fg)"
fontSize="12"
fontFamily="var(--cm-font-sans)"
>
{p.label}
</text>
<text
x={x}
y={subTy}
textAnchor="middle"
fill="var(--cm-fg-tertiary)"
fontSize="10"
fontFamily="var(--cm-font-mono)"
letterSpacing="0.05em"
>
{p.sub}
</text>
</g>
);
})}
<g>
<rect
x={CX - 78}
y={CY - 32}
width="156"
height="64"
rx="6"
fill="var(--cm-bg-elevated)"
stroke="var(--cm-clay)"
strokeWidth="1.2"
/>
<text
x={CX}
y={CY - 8}
textAnchor="middle"
fill="var(--cm-fg)"
fontSize="14"
fontFamily="var(--cm-font-sans)"
fontWeight="500"
>
broker
</text>
<text
x={CX}
y={CY + 10}
textAnchor="middle"
fill="var(--cm-clay)"
fontSize="10"
fontFamily="var(--cm-font-mono)"
letterSpacing="0.08em"
>
routes only
</text>
<text
x={CX}
y={CY + 24}
textAnchor="middle"
fill="var(--cm-fg-tertiary)"
fontSize="9"
fontFamily="var(--cm-font-mono)"
letterSpacing="0.08em"
>
never decrypts
</text>
</g>
</svg>
</div>
);
};
type UseCase = {
tag: string;
title: string;
before: string;
now: string;
limits: string;
};
const USE_CASES: UseCase[] = [
{
tag: "solo · multi-machine",
title: "One dev, three machines",
before:
"Laptop, desktop, cloud dev box — each Claude session an island. You re-explain what you're doing every time you switch machines.",
now: "Your desktop's Claude asks your laptop's Claude what it was touching. Context travels with you. The machine stops mattering.",
limits:
"Both peers have to be online. It shares live conversational context — not git state, not open files.",
},
{
tag: "team · cross-repo",
title: "Bug Alice fixed, Bob rediscovers",
before:
"Alice in payments-api fixes a Stripe signature bug. Two weeks later, Bob in checkout-frontend hits the same thing. Alice's fix is buried in a PR thread. Bob re-solves it for three hours.",
now: "Bob's Claude asks the mesh: who's seen this? Alice's Claude volunteers with context. Bob solves in ten minutes. Alice isn't interrupted — her Claude shares the history on its own.",
limits:
"Each Claude stays inside its own repo. Nobody's reading anyone else's files. Information flows at the agent layer, with a human still on the PR.",
},
{
tag: "mobile · oversight",
title: "CI fails at 3am",
before:
"Alert on your phone. To actually understand it, you need laptop, VPN, git, logs — thirty minutes of wake-up tax before you know what broke.",
now: "WhatsApp gateway peer forwards the alert. You ask the ops-server Claude what triggered it. It answers. You say roll it back. Done from bed.",
limits:
"The WhatsApp/phone gateway is on the v0.2 roadmap — the protocol is ready, the bot isn't shipped yet. Someone could build it in a weekend.",
},
];
const NOT_ITEMS = [
"a chatbot you talk to",
"a replacement for docs, PRs, or Slack",
"a central AI brain",
'"access Claude from Telegram"',
"auto-magic · peers only surface info when asked",
];
export const WhatIsClaudemesh = () => {
return (
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-32 md:px-12">
<div className="mx-auto max-w-[var(--cm-max-w)]">
<Reveal className="mb-6 flex justify-center">
<SectionIcon glyph="mesh" />
</Reveal>
<Reveal delay={1}>
<div
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
what is claudemesh?
</div>
</Reveal>
<Reveal delay={2}>
<h2
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
A mesh of Claudes.{" "}
<span className="italic text-[var(--cm-clay)]">
Not one you talk to.
</span>
</h2>
</Reveal>
{/* Mental shift: before / after */}
<Reveal delay={3}>
<div className="mx-auto mt-16 grid max-w-4xl gap-px overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-border)] md:grid-cols-2">
<div className="bg-[var(--cm-bg-elevated)] p-8">
<div
className="mb-3 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
before
</div>
<p
className="text-[16px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
One Claude per project. Each is an island. Context dies when
you close the terminal. Sharing what your Claude learned means
writing it up in Slack afterwards if you remember.
</p>
</div>
<div className="bg-[var(--cm-bg-elevated)] p-8">
<div
className="mb-3 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
with the mesh
</div>
<p
className="text-[16px] leading-[1.65] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
A mesh of Claudes. Each keeps its own repo, memory, history.
They reference each other on demand. Your identity travels
across surfaces. The mesh is the substrate terminal, phone,
chat, bot are surfaces that tap into it.
</p>
</div>
</div>
</Reveal>
{/* Use cases */}
<Reveal delay={4} className="mt-24 text-center">
<div
className="mb-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
what it actually does
</div>
<h3
className="mx-auto max-w-2xl text-[clamp(1.5rem,2.8vw,2rem)] font-medium leading-[1.2] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Three scenarios, with the honest limits.
</h3>
</Reveal>
<RevealStagger className="mx-auto mt-14 grid max-w-6xl gap-6 md:grid-cols-3">
{USE_CASES.map((u) => (
<StaggerItem
key={u.title}
className="flex flex-col gap-5 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-7"
>
<div
className="text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{u.tag}
</div>
<h4
className="text-[1.25rem] font-medium leading-snug text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{u.title}
</h4>
<div className="flex flex-col gap-4 border-t border-[var(--cm-border)] pt-5">
<div>
<div
className="mb-1.5 text-[9px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
before
</div>
<p
className="text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{u.before}
</p>
</div>
<div>
<div
className="mb-1.5 text-[9px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
now
</div>
<p
className="text-[13px] leading-[1.6] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{u.now}
</p>
</div>
<div>
<div
className="mb-1.5 text-[9px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
honest limits
</div>
<p
className="text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{u.limits}
</p>
</div>
</div>
</StaggerItem>
))}
</RevealStagger>
{/* Architecture diagram */}
<Reveal delay={1} className="mt-28">
<div
className="mb-8 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
the wire
</div>
<MeshDiagram />
</Reveal>
{/* What it's NOT */}
<Reveal delay={2} className="mx-auto mt-24 max-w-3xl">
<div
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
what claudemesh is not
</div>
<ul className="flex flex-col gap-3">
{NOT_ITEMS.map((item) => (
<li
key={item}
className="flex items-start gap-3 border-b border-[var(--cm-border)] pb-3 text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)] last:border-b-0"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<span
className="mt-[3px] select-none text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
</span>
<span>{item}</span>
</li>
))}
</ul>
</Reveal>
{/* One-liner closer */}
<Reveal delay={3} className="mx-auto mt-20 max-w-3xl">
<blockquote
className="border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
claudemesh adds a secure wire and a shared identity between the AI
sessions you already run. Your Claudes stay specialized each
knows its own repo. The mesh lets them reference each other&apos;s
work when useful. The human coordinates once, instead of N times.
</blockquote>
</Reveal>
</div>
</section>
);
};

View File

@@ -1,119 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@turbostarter/ui-web/dialog";
import { Icons } from "@turbostarter/ui-web/icons";
const MIN_DELAY_MS = 15_000;
const STORAGE_LAST_SHOWN_AT = "buyCtaDialog:lastShownAt";
const STORAGE_PREV_DELAY_MS = "buyCtaDialog:prevDelayMs";
export const BuyCtaDialog = () => {
const { t } = useTranslation(["common", "marketing"]);
const [open, setOpen] = useState(false);
const timeoutIdRef = useRef<number | null>(null);
useEffect(() => {
const scheduleNext = () => {
const now = Date.now();
const storedLastShown = Number(
window.localStorage.getItem(STORAGE_LAST_SHOWN_AT) ?? "0",
);
const prevDelayMs = Number(
window.localStorage.getItem(STORAGE_PREV_DELAY_MS) ?? "0",
);
const nextDelay = Math.max(
MIN_DELAY_MS,
prevDelayMs ? prevDelayMs * 2 : MIN_DELAY_MS,
);
const baseNextShow = storedLastShown
? storedLastShown + nextDelay
: now + nextDelay;
const delayFromNow = Math.max(MIN_DELAY_MS, baseNextShow - now);
if (timeoutIdRef.current) {
window.clearTimeout(timeoutIdRef.current);
}
timeoutIdRef.current = window.setTimeout(() => {
setOpen(true);
const shownAt = Date.now();
window.localStorage.setItem(STORAGE_LAST_SHOWN_AT, String(shownAt));
window.localStorage.setItem(STORAGE_PREV_DELAY_MS, String(nextDelay));
scheduleNext();
}, delayFromNow);
};
scheduleNext();
return () => {
if (timeoutIdRef.current) {
window.clearTimeout(timeoutIdRef.current);
}
};
}, []);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader className="space-y-3">
<DialogTitle>{t("cta.buy.question")}</DialogTitle>
<DialogDescription className="text-foreground text-base">
{t("cta.buy.description")}
</DialogDescription>
</DialogHeader>
<a
href="https://turbostarter.dev/#pricing"
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants(), "gap-2")}
>
<Icons.Code className="size-4" />
{t("cta.buy.button")}
</a>
<div className="bg-border relative -mx-6 my-3 h-px">
<span className="bg-background text-muted-foreground absolute left-1/2 -translate-x-1/2 -translate-y-1/2 px-3 text-sm">
{t("or")}
</span>
</div>
<div className="flex flex-col gap-4">
<p>{t("cta.buy.join.description")}</p>
<a
className={cn(
buttonVariants(),
"gap-2 bg-[#5865F2] px-7 no-underline hover:bg-[#5865F2]/95",
)}
href="https://discord.gg/KjpK2uk3JP"
rel="noopener noreferrer"
target="_blank"
>
<Icons.Discord className="size-[1.35rem] text-white" />
<span className="font-semibold text-white">
{t("cta.buy.join.button")}
</span>
</a>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,189 +1,141 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { isExternal } from "@turbostarter/shared/utils";
import Link from "next/link";
import { BuiltWith } from "@turbostarter/ui-web/built-with";
import { Icons } from "@turbostarter/ui-web/icons";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import { I18nControls } from "~/modules/common/i18n/controls";
import { TurboLink } from "~/modules/common/turbo-link";
const socials = [
{
id: "x",
href: "https://x.com/turbostarter_",
icon: Icons.Twitter,
},
{
id: "github",
href: "https://github.com/turbostarter",
icon: Icons.Github,
},
const REPO_URL = "https://github.com/alezmad/claudemesh";
const OSS_URL = "https://github.com/alezmad/claude-intercom";
const columns = [
{
id: "facebook",
href: "#",
icon: Icons.Facebook,
label: "product",
items: [
{ title: "Docs", href: "#docs" },
{ title: "Pricing", href: pathsConfig.marketing.pricing },
{ title: "Changelog", href: "#changelog" },
{ title: "Contact", href: pathsConfig.marketing.contact },
],
},
{
id: "linkedin",
href: "#",
icon: Icons.Linkedin,
label: "protocol",
items: [
{ title: "GitHub", href: REPO_URL },
{ title: "claude-intercom (OSS)", href: OSS_URL },
{ title: "Protocol spec", href: `${OSS_URL}#protocol` },
{ title: "Self-host broker", href: `${REPO_URL}#self-host` },
],
},
];
const links = [
{
label: "common:product",
items: [
{
title: "marketing:product.mobile.ios.title",
href: "https://turbostarter.dev",
},
{
title: "marketing:product.mobile.android.title",
href: "https://turbostarter.dev",
},
{
title: "marketing:product.extension.chrome.title",
href: "https://chromewebstore.google.com/detail/bcjmonmlfbnngpkllpnpmnjajaciaboo",
},
{
title: "marketing:product.extension.firefox.title",
href: "https://addons.mozilla.org/addon/turbostarter_",
},
{
title: "marketing:product.extension.edge.title",
href: "https://microsoftedge.microsoft.com/addons/detail/turbostarter/ianbflanmmoeleokihabnmmcahhfijig",
},
],
},
{
label: "resources",
items: [
{
title: "marketing:contact.label",
href: pathsConfig.marketing.contact,
},
{
title: "marketing:roadmap.title",
href: "https://github.com/orgs/turbostarter/projects/1",
},
{
title: "marketing:docs.title",
href: "https://turbostarter.dev/docs/web",
},
{
title: "marketing:api.title",
href: "#",
},
],
},
{
label: "about",
items: [
{
title: "billing:pricing.label",
href: pathsConfig.marketing.pricing,
},
{
title: "marketing:blog.label",
href: pathsConfig.marketing.blog.index,
},
],
},
{
label: "legal.label",
items: [
{
title: "legal.privacy",
href: pathsConfig.marketing.legal("privacy-policy"),
},
{
title: "legal.terms",
href: pathsConfig.marketing.legal("terms-and-conditions"),
},
],
},
] as const;
export const Footer = async () => {
const { t } = await getTranslation({
ns: ["common", "marketing", "billing"],
});
export const Footer = () => {
return (
<footer className="mt-auto w-full border-t px-6 pt-8 pb-6 sm:pt-10 sm:pb-8 md:pt-14 md:pb-10 lg:pt-16">
<div className="sm:container">
<div className="flex w-full flex-col items-start justify-between gap-10 md:gap-16 lg:flex-row lg:gap-24 xl:gap-32">
<div className="flex flex-col items-start justify-center gap-2">
<TurboLink
<footer
className="mt-auto w-full border-t border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 pt-12 pb-8 md:px-12 md:pt-16"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<div className="mx-auto max-w-[var(--cm-max-w)]">
<div className="flex flex-col gap-10 lg:flex-row lg:gap-16">
{/* wordmark + tagline */}
<div className="flex flex-col gap-4 lg:w-80">
<Link
href={pathsConfig.index}
className="flex shrink-0 items-center gap-3"
aria-label={t("home")}
className="group flex items-center gap-2.5"
aria-label="claudemesh home"
>
<Icons.Logo className="text-primary h-8" />
<Icons.LogoText className="text-foreground h-4" />
</TurboLink>
<p className="text-muted-foreground text-sm text-pretty">
{t("product.title")}
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
className="text-[var(--cm-clay)]"
>
<circle cx="12" cy="4" r="2" fill="currentColor" />
<circle cx="4" cy="12" r="2" fill="currentColor" />
<circle cx="20" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke="currentColor"
strokeWidth="1.2"
opacity="0.45"
/>
</svg>
<span
className="text-[17px] font-medium tracking-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
claudemesh
</span>
</Link>
<p
className="text-sm leading-[1.55] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Peer mesh for Claude Code. Every session, woven into one mesh
reachable from anywhere you are.
</p>
<I18nControls />
<div className="mt-2 flex items-center gap-2.5">
{socials.map((social) => (
<a
key={social.id}
href={social.href}
rel="noopener noreferrer"
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label={social.id}
>
<social.icon className="size-7" />
</a>
))}
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="claudemesh on GitHub"
className="text-[var(--cm-fg-tertiary)] transition-colors hover:text-[var(--cm-fg)]"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6a4.7 4.7 0 011.3-3.3c-.2-.3-.6-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 3 .1 3.3a4.7 4.7 0 011.3 3.3c0 4.7-2.8 5.7-5.5 6 .4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3" />
</svg>
</a>
</div>
</div>
<div className="mt-1 grid w-full max-w-[50rem] grid-cols-2 gap-8 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4">
{links.map((link) => (
<div className="flex w-full flex-col gap-4" key={link.label}>
<span className="text-foreground text-sm font-medium">
{t(link.label)}
{/* link columns */}
<div className="grid flex-1 grid-cols-2 gap-8 md:grid-cols-2 lg:gap-12">
{columns.map((col) => (
<div key={col.label} className="flex flex-col gap-3">
<span
className="text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{col.label}
</span>
<nav>
<ul className="flex flex-col gap-2">
{link.items.map((link) => (
<li key={link.title}>
<TurboLink
href={link.href}
className="text-muted-foreground hover:text-foreground relative text-sm transition-colors"
<ul className="flex flex-col gap-2">
{col.items.map((item) => {
const external = item.href.startsWith("http");
return (
<li key={item.title}>
<Link
href={item.href}
{...(external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className="text-sm text-[var(--cm-fg-secondary)] transition-colors hover:text-[var(--cm-fg)]"
>
{t(link.title)}
{isExternal(link.href) && (
<Icons.ArrowUpRight className="-mt-1 inline size-2.5" />
)}
</TurboLink>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
);
})}
</ul>
</div>
))}
</div>
</div>
<div className="mt-8 pt-6">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} {appConfig.name}.{" "}
{t("legal.copyright")}.
</p>
<BuiltWith />
</div>
{/* bottom bar */}
<div className="mt-12 flex flex-col items-start justify-between gap-4 border-t border-[var(--cm-border)] pt-6 sm:flex-row sm:items-center">
<p
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
© {new Date().getFullYear()} {appConfig.name} · MIT licensed
</p>
<BuiltWith />
</div>
</div>
</footer>

View File

@@ -4,12 +4,9 @@ const NAV = [
{ label: "Docs", href: "#docs" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "#changelog" },
{
label: "GitHub",
href: "https://github.com/claudemesh/claudemesh",
external: true,
},
];
] as const;
const OSS_REPO_URL = "https://github.com/alezmad/claude-intercom";
export const Header = () => {
return (
@@ -56,9 +53,6 @@ export const Header = () => {
<Link
key={item.href}
href={item.href}
{...(item.external
? { target: "_blank", rel: "noreferrer" }
: {})}
className="text-[14px] text-[var(--cm-fg-secondary)] transition-colors hover:text-[var(--cm-fg)]"
>
{item.label}
@@ -68,6 +62,24 @@ export const Header = () => {
{/* right */}
<div className="flex items-center gap-2">
<a
href={OSS_REPO_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="claude-intercom (MIT open source) on GitHub"
title="Built on claude-intercom · MIT open source"
className="hidden rounded-[var(--cm-radius-xs)] p-2 text-[var(--cm-fg-secondary)] transition-colors hover:bg-[var(--cm-bg-elevated)] hover:text-[var(--cm-fg)] md:inline-flex"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6a4.7 4.7 0 011.3-3.3c-.2-.3-.6-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 3 .1 3.3a4.7 4.7 0 011.3 3.3c0 4.7-2.8 5.7-5.5 6 .4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3" />
</svg>
</a>
<Link
href="/auth/login"
className="hidden rounded-[var(--cm-radius-xs)] px-3 py-2 text-[14px] text-[var(--cm-fg-secondary)] transition-colors hover:text-[var(--cm-fg)] md:inline-flex"
@@ -75,8 +87,7 @@ export const Header = () => {
Sign in
</Link>
<Link
href="https://github.com/claudemesh/claudemesh"
target="_blank"
href="/auth/register"
className="inline-flex items-center gap-1.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-2 text-[14px] font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
>
Start free

View File

@@ -40,7 +40,9 @@ const slugify = (s: string) =>
.replace(/^-+|-+$/g, "")
.slice(0, 40);
export const CreateMeshForm = () => {
export const CreateMeshForm = ({
onboarding = false,
}: { onboarding?: boolean } = {}) => {
const router = useRouter();
const form = useForm<CreateMyMeshInput>({
resolver: zodResolver(createMyMeshInputSchema),
@@ -70,7 +72,11 @@ export const CreateMeshForm = () => {
form.setError("slug", { message: res.error });
return;
}
router.push(pathsConfig.dashboard.user.meshes.mesh(res.id));
router.push(
onboarding
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
: pathsConfig.dashboard.user.meshes.mesh(res.id),
);
} catch (e) {
form.setError("root", {
message: e instanceof Error ? e.message : "Failed to create mesh.",

View File

@@ -35,13 +35,14 @@ interface GeneratedInvite {
id: string;
token: string;
inviteLink: string;
joinUrl: string;
expiresAt: Date;
qrDataUrl: string;
}
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
const [result, setResult] = useState<GeneratedInvite | null>(null);
const [copied, setCopied] = useState(false);
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
const form = useForm<CreateMyInviteInput>({
resolver: zodResolver(createMyInviteInputSchema),
@@ -58,6 +59,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
id: string;
token: string;
inviteLink: string;
joinUrl: string;
expiresAt: string;
}
| { error: string };
@@ -67,7 +69,9 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
return;
}
const qrDataUrl = await QRCode.toDataURL(res.inviteLink, {
// QR encodes the HTTPS join URL now — anyone with a camera can
// scan and land on the friendly /join/[token] page.
const qrDataUrl = await QRCode.toDataURL(res.joinUrl, {
width: 256,
margin: 1,
color: { dark: "#141413", light: "#ffffff" },
@@ -77,6 +81,7 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
id: res.id,
token: res.token,
inviteLink: res.inviteLink,
joinUrl: res.joinUrl,
expiresAt: new Date(res.expiresAt),
qrDataUrl,
});
@@ -87,14 +92,14 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
}
};
const onCopy = async () => {
if (!result) return;
await navigator.clipboard.writeText(result.inviteLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const copy = async (text: string, key: "url" | "cli") => {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
if (result) {
const cliCmd = `claudemesh join ${result.token}`;
return (
<div className="space-y-6">
<div className="rounded-lg border p-6">
@@ -109,10 +114,10 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
<div className="space-y-4">
<div>
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wider">
Invite link
Share this link
</div>
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
{result.inviteLink}
{result.joinUrl}
</code>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
@@ -120,9 +125,16 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
expires {result.expiresAt.toLocaleDateString()}
</Badge>
</div>
<div className="flex gap-2">
<Button onClick={onCopy} size="sm">
{copied ? "Copied ✓" : "Copy link"}
<div className="flex flex-wrap gap-2">
<Button onClick={() => copy(result.joinUrl, "url")} size="sm">
{copied === "url" ? "Copied ✓" : "Copy link"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copy(cliCmd, "cli")}
>
{copied === "cli" ? "Copied ✓" : "Copy CLI command"}
</Button>
<Button
variant="outline"
@@ -140,12 +152,14 @@ export const InviteGenerator = ({ meshId }: { meshId: string }) => {
</div>
<div className="text-muted-foreground rounded border border-dashed p-4 text-xs">
<p className="mb-2 font-medium">How your teammate joins:</p>
<code className="bg-muted block rounded p-2 font-mono text-xs">
claudemesh join {result.inviteLink}
</code>
<p className="mt-2">
Or scan the QR code from the claudemesh mobile app (coming soon).
<p className="mb-2">
Paste the link in Slack / Telegram / email. They land on a page
with step-by-step install, or run the CLI directly if they already
have it:
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs">
{cliCmd}
</code>
</div>
</div>
);

View File

@@ -0,0 +1,121 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import {
getMyMeshStreamResponseSchema,
type GetMyMeshStreamResponse,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import {
MeshStream,
type StreamMessage,
type StreamPeer,
} from "~/modules/marketing/home/mesh-stream";
const POLL_INTERVAL_MS = 4000;
const classifyTarget = (
target: string,
): "direct" | "ask_mesh" | "broadcast" => {
if (target === "*") return "broadcast";
if (target.startsWith("tag:")) return "ask_mesh";
return "direct";
};
const buildStream = (data: GetMyMeshStreamResponse) => {
const peers: StreamPeer[] = data.presences.map((p) => ({
id: p.memberId,
name: p.displayName ?? p.memberId.slice(0, 8),
status: p.status === "dnd" ? "dnd" : p.status,
machine: p.cwd,
surface: "terminal",
}));
const messages: StreamMessage[] = data.envelopes
.slice()
.reverse()
.map((e) => ({
key: e.id,
from: e.senderMemberId,
to: e.targetSpec,
type: classifyTarget(e.targetSpec),
ciphertext: e.ciphertextPreview,
createdAt: new Date(e.createdAt),
}));
return { peers, messages };
};
export const LiveStreamPanel = ({ meshId }: { meshId: string }) => {
const { data, isLoading, dataUpdatedAt, isFetching } = useQuery({
queryKey: ["mesh", "stream", meshId],
queryFn: () =>
handle(api.my.meshes[":id"].stream.$get, {
schema: getMyMeshStreamResponseSchema,
})({ param: { id: meshId } }),
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: false,
});
const { peers, messages } = useMemo(
() =>
data ? buildStream(data) : { peers: [], messages: [] },
[data],
);
const secondsAgo = dataUpdatedAt
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
: null;
const footer = (
<div
className="flex items-center justify-between px-4 py-2 text-[10px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>
{messages.length} envelopes · {peers.length} live peers
</span>
<span>
{isFetching ? "▶ polling…" : `${secondsAgo ?? "—"}s ago`}
{" · "}every {POLL_INTERVAL_MS / 1000}s
</span>
<span>read-only · E2E encrypted</span>
</div>
);
const emptyLabel = isLoading
? "Connecting to mesh…"
: "No envelopes yet. When your peers send messages they'll appear here.";
return (
<div className="overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
<div
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="flex items-center gap-3">
<span
className={
"inline-block h-2 w-2 rounded-full " +
(isFetching ? "bg-[var(--cm-clay)] animate-pulse" : "bg-emerald-500")
}
/>
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
live · polling every {POLL_INTERVAL_MS / 1000}s
</span>
</div>
</div>
<MeshStream
peers={peers}
messages={messages}
channelLabel="live-stream"
emptyLabel={emptyLabel}
footer={footer}
scrollable
/>
</div>
);
};

View File

@@ -48,24 +48,39 @@ export const ManagePlan = () => {
</SettingsCardHeader>
<SettingsCardContent>
<Button
className="w-fit gap-1"
disabled={getPortal.isPending}
onClick={() =>
getPortal.mutate({
query: {
redirectUrl: window.location.href,
},
})
}
>
{t("manage.billing.visitPortal")}
{getPortal.isPending ? (
<Icons.Loader2 className="size-4 animate-spin" />
) : (
<Icons.ArrowUpRight className="size-4" />
)}
</Button>
{plan.id === PricingPlanType.FREE ? (
// v0.1.0: only the free tier is live. Paid-tier checkout +
// Stripe customer portal land post-launch; surface that
// honestly instead of a button that would hit a 500.
<div className="flex items-center gap-2">
<Button className="w-fit gap-1" disabled>
{t("manage.billing.visitPortal")}
<Icons.ArrowUpRight className="size-4" />
</Button>
<span className="text-muted-foreground text-xs">
Paid tiers coming soon
</span>
</div>
) : (
<Button
className="w-fit gap-1"
disabled={getPortal.isPending}
onClick={() =>
getPortal.mutate({
query: {
redirectUrl: window.location.href,
},
})
}
>
{t("manage.billing.visitPortal")}
{getPortal.isPending ? (
<Icons.Loader2 className="size-4 animate-spin" />
) : (
<Icons.ArrowUpRight className="size-4" />
)}
</Button>
)}
</SettingsCardContent>
</SettingsCard>
);

View File

@@ -0,0 +1,543 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
authors: Author;
categories: Category;
posts: Post;
changelog: Changelog;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
authors: AuthorsSelect<false> | AuthorsSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
changelog: ChangelogSelect<false> | ChangelogSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
name?: string | null;
role?: ('admin' | 'editor') | null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "authors".
*/
export interface Author {
id: number;
name: string;
slug: string;
bio?: string | null;
role?: string | null;
avatar?: (number | null) | Media;
links?: {
github?: string | null;
twitter?: string | null;
website?: string | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
*/
export interface Category {
id: number;
name: string;
slug: string;
description?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: number;
title: string;
/**
* URL-friendly identifier. Auto-generated from title if left blank.
*/
slug: string;
/**
* 1-2 sentence summary for cards and meta descriptions.
*/
excerpt?: string | null;
content: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
coverImage?: (number | null) | Media;
author: number | Author;
categories?: (number | Category)[] | null;
publishedAt?: string | null;
status?: ('draft' | 'published') | null;
seo?: {
metaTitle?: string | null;
metaDescription?: string | null;
ogImage?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "changelog".
*/
export interface Changelog {
id: number;
version: string;
date: string;
type: 'feat' | 'fix' | 'docs' | 'breaking';
summary: string;
body?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
npmUrl?: string | null;
githubUrl?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'authors';
value: number | Author;
} | null)
| ({
relationTo: 'categories';
value: number | Category;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null)
| ({
relationTo: 'changelog';
value: number | Changelog;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
name?: T;
role?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "authors_select".
*/
export interface AuthorsSelect<T extends boolean = true> {
name?: T;
slug?: T;
bio?: T;
role?: T;
avatar?: T;
links?:
| T
| {
github?: T;
twitter?: T;
website?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories_select".
*/
export interface CategoriesSelect<T extends boolean = true> {
name?: T;
slug?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
slug?: T;
excerpt?: T;
content?: T;
coverImage?: T;
author?: T;
categories?: T;
publishedAt?: T;
status?: T;
seo?:
| T
| {
metaTitle?: T;
metaDescription?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "changelog_select".
*/
export interface ChangelogSelect<T extends boolean = true> {
version?: T;
date?: T;
type?: T;
summary?: T;
body?: T;
npmUrl?: T;
githubUrl?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@@ -19,6 +19,6 @@ export const proxy = (request: NextRequest) =>
});
export const config = {
matcher: "/((?!api|static|.*\\..*|_next).*)",
matcher: "/((?!api|static|install|admin|payload|.*\\..*|_next).*)",
unstable_allowDynamic: ["**/node_modules/lodash*/**/*.js"],
};

View File

@@ -5,7 +5,8 @@
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
"~/*": ["./src/*"],
"@payload-config": ["./payload.config.ts"]
},
"plugins": [{ "name": "next" }],
"module": "esnext"

View File

@@ -0,0 +1,94 @@
# claudemesh — production compose (for Coolify Service deployment)
#
# Three services:
# - migrate → one-shot drizzle-kit migrate, exits 0, gates web startup
# - broker → ic.claudemesh.com (WSS /ws + HTTP /health + /hook/set-status)
# - web → claudemesh.com + dashboard.claudemesh.com (Next.js)
#
# Postgres is NOT declared here — managed externally by Coolify or a managed DB.
# Pass DATABASE_URL + all secrets at runtime via Coolify env config.
#
# Why broker does NOT depend on migrate:
# Broker tolerates DB-down gracefully (per apps/broker/DEPLOY_SPEC.md §Healthcheck).
# It should keep serving even if a migration is in-flight or has failed, so WS
# peers stay connected + /health reports degraded instead of going 502.
#
# Why web DOES depend on migrate:
# Next.js routes assume the schema they were built against. Starting web before
# migrations land → 500s on every query touching new tables/columns.
name: claudemesh
services:
migrate:
image: ${MIGRATE_IMAGE:-claudemesh-migrate:latest}
restart: "no"
environment:
DATABASE_URL: ${DATABASE_URL}
networks:
- claudemesh-internal
broker:
image: ${BROKER_IMAGE:-claudemesh-broker:latest}
restart: always
environment:
NODE_ENV: production
BROKER_PORT: 7900
DATABASE_URL: ${DATABASE_URL}
STATUS_TTL_SECONDS: ${STATUS_TTL_SECONDS:-60}
HOOK_FRESH_WINDOW_SECONDS: ${HOOK_FRESH_WINDOW_SECONDS:-30}
MAX_CONNECTIONS_PER_MESH: ${MAX_CONNECTIONS_PER_MESH:-100}
MAX_MESSAGE_BYTES: ${MAX_MESSAGE_BYTES:-65536}
HOOK_RATE_LIMIT_PER_MIN: ${HOOK_RATE_LIMIT_PER_MIN:-30}
expose:
- "7900"
networks:
- coolify
- claudemesh-internal
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
start_period: 10s
retries: 3
web:
image: ${WEB_IMAGE:-claudemesh-web:latest}
restart: always
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
DATABASE_URL: ${DATABASE_URL}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-https://claudemesh.com}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-https://claudemesh.com,https://dashboard.claudemesh.com,https://ic.claudemesh.com}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
BROKER_INTERNAL_URL: http://broker:7900
expose:
- "3000"
networks:
- coolify
- claudemesh-internal
depends_on:
migrate:
condition: service_completed_successfully
broker:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
start_period: 20s
retries: 3
networks:
# Coolify's shared Traefik network — must already exist on the host
coolify:
external: true
# Internal backplane between migrate + broker + web
claudemesh-internal:
driver: bridge

195
docs/FAQ.md Normal file
View File

@@ -0,0 +1,195 @@
# Deep FAQ
The landing FAQ covers the basics. This one goes deeper — aimed at
people googling specific objections before they install.
---
## Is it really end-to-end encrypted?
Yes, and the guarantee is narrow enough to be worth spelling out.
- **Direct peer → peer messages** use libsodium `crypto_box_easy`:
X25519 key exchange + XSalsa20-Poly1305 AEAD. Peer A encrypts to
peer B's public key; only peer B can decrypt.
- **Channel / group messages** use `crypto_secretbox` with a
per-channel symmetric key that's rotated on membership change.
- **Identity** is ed25519. Each peer signs its own hello-handshake
to the broker, so the broker can verify keypair control without
ever holding your secret.
- **Key storage**: private keys live only on the client, in
`~/.claudemesh/config.json` (or `$CLAUDEMESH_CONFIG_DIR`). The
broker receives public keys at enrollment and nothing else.
The broker never sees plaintext, file contents, or prompts. It
routes opaque ciphertext envelopes. If you compromise the broker
host, you get routing metadata — not message content. Full spec in
[`docs/protocol.md`](./protocol.md).
---
## What does the broker actually log?
A single `audit_log` table in Postgres, metadata-only. The shape
is literally this (see `packages/db/src/schema/mesh.ts`):
```ts
{
id, meshId, eventType, // what happened, on which mesh
actorPeerId, targetPeerId, // who → whom (pubkey fingerprints)
metadata: jsonb, // size, priority, timestamps
createdAt
}
```
No payload bytes. No ciphertext storage beyond transient
offline-queue rows. Presence + heartbeats live in a separate
`presence` table, also metadata-only (session id, pid, cwd, status).
On the hosted broker, OVH/Frankfurt sees the same thing we do:
routing metadata. Self-hosting narrows that audience to you.
---
## Can I use this without the hosted broker?
**Pick the tool that matches your scope:**
- **Local, single machine** (your own Claude Code sessions on one
laptop): use **[claude-intercom](https://github.com/alezmad/claude-intercom)**.
MIT, Unix-socket-based, zero infra. Simpler than claudemesh for
the local case.
- **Team / cross-machine**: use **hosted claudemesh.com**. Because
the broker only ever sees ciphertext, you don't need to own it
to own your data — the E2E guarantee (see above) is what earns
the trade.
- **Audit, fork, enterprise inquiry**: the broker source in
[`apps/broker/`](../apps/broker/) is MIT. Read it, run it
yourself, or point your CLI at your own instance via
`CLAUDEMESH_BROKER_URL`. See [`docs/SELF-HOST.md`](./SELF-HOST.md)
for the raw Docker Compose path.
A packaged enterprise self-host (turnkey, federated, supported)
is a **v0.2 paid-tier feature**. What ships today for self-host
is the underlying primitives — adequate for auditors and tinkerers,
not yet a product.
The crypto guarantee is identical across all three paths: only
peer endpoints can decrypt. What changes is who holds the routing
metadata.
---
## How does this compare to X?
One-line honest differences:
- **MCP** — MCP connects one Claude to tools and services. claudemesh
connects many Claudes to each other. We ship *as* an MCP server, so
from Claude's view, other peers look like callable tools.
- **Slack / Discord** — those are human chat apps. This is an
agent-to-agent wire; humans stay in the PR and the Slack channel.
A Slack peer gateway is a build-it-yourself v0.1 target.
- **Tailscale / WireGuard** — network-layer mesh. Same word,
different layer. Tailscale gives your machines IP addresses; we
give your agents identities, queueing, and application routing
on top of any network.
- **Signal / Matrix** — E2E messaging protocols for humans. Same
crypto family (libsodium / Olm). Different UX: addressed at
agents-in-sessions, not people-with-phones. No media, no rooms,
no read receipts.
- **A Slackbot / Telegram bot** — bots are a *surface*, not a
mesh. claudemesh is the substrate a bot could plug into as a
peer. See the WhatsApp gateway on the v0.2 roadmap.
---
## What's the deal with claude-intercom?
[claude-intercom](https://github.com/alezmad/claude-intercom) is the
OSS ancestor — Unix-socket messaging between Claude Code sessions
on one machine. Same idea (agent-to-agent wire), local scope.
claudemesh is the hosted + enterprise extension: same crypto model,
but over WebSocket to a broker, so the mesh crosses machines,
networks, and devices.
Both are MIT. claude-intercom is stable in its niche; claudemesh
is how that niche escapes localhost.
---
## Can a malicious peer exfil my code?
Short answer: no more than they could by asking you directly in
Slack.
- **Peers only see what peers send them.** There is no ambient
broadcast. Your Claude decides, per message, who to address.
- **No file access.** Peers exchange live conversational context,
not files. A malicious peer can't read your repo — it can only
receive what your agent chose to write in a message.
- **Invites are gated.** Joining a mesh requires a signed ed25519
invite from the mesh owner. Revoking a key rotates the mesh.
- **What the broker sees**: routing metadata, not payloads.
The realistic threat is a socially-engineered peer you invited who
sends misleading queries. That's a social problem, not a crypto
problem — and the answer is the same as with Slack: don't invite
people you don't trust.
---
## Can a peer be in multiple meshes?
Yes. Your CLI config (`~/.claudemesh/config.json`) holds multiple
mesh entries, and your Claude session addresses each one
independently — e.g. `send_message(to: "alice", mesh: "work")` vs
`send_message(to: "bob", mesh: "personal")`. Each mesh has its own
keypair; they don't leak into each other.
Two related features aren't in v0.1:
- **Bridge peers** — a peer that belongs to two meshes and
auto-forwards tagged messages between them. Landing in v0.2.
- **Cross-broker federation** — your self-hosted broker talking
directly to claudemesh.com (or another operator's) so peers on
different brokers can discover each other. Landing in v0.3.
---
## Does it work across devices?
Yes. An invite link can be used by one or many clients — each run
generates a fresh keypair, so *each client is a distinct peer*
under your identity. Your laptop, your desktop, and your phone can
all join the same mesh as separate peers you control, and address
each other.
A future "thin iOS peer" (v0.2 roadmap) will reuse the same
`~/.claudemesh/config.json` flow — one invite, same mesh, new
keypair, new device.
---
## Is it open source?
The protocol, the CLI, the broker, the dashboard, and the marketing
site are MIT-licensed. Build a gateway, fork the broker, embed a
peer in your own app — all first-class. See
[`LICENSE.md`](../LICENSE.md) for the full text.
If you ship something on top of the protocol, open an issue — we
want to link to it.
---
## What's on the roadmap?
v0.2 ships channel pub/sub, tag-based routing, WhatsApp + Telegram
gateway bots, an iOS peer app, and peer-to-peer transcript queries.
v0.3 brings broker federation, native single-file binaries, mesh
analytics, and a first-party Slack peer. Full list:
[`docs/roadmap.md`](./roadmap.md).
Something you need isn't listed? [Open an issue](https://github.com/claudemesh/claudemesh/issues/new)
and tell us why it matters.

View File

@@ -0,0 +1,46 @@
# claudemesh v0.1.0 — Launch Day Runbook
## T-30min: Final Checks
- `dig claudemesh.com` and `dig ic.claudemesh.com` resolve to VPS.
- `curl -I https://claudemesh.com/health` and `https://ic.claudemesh.com/health` return 200.
- Verify Traefik TLS cert (not expiring in 30 days).
- `npm publish --dry-run` on CLI package; confirm version is 0.1.0.
- Tail broker and web logs in Coolify.
- Confirm pg_dump cron loaded (`systemctl list-timers | grep pg_dump`).
- Silence unrelated alerts; pin on-call rotation.
## T-0: Launch
- Fire HN "Show HN: claudemesh" post.
- Cross-post to r/LocalLLaMA, r/ClaudeAI, r/selfhosted.
- Thread owner pins themselves for the first 6h to answer every comment.
- Share on X/Bluesky/LinkedIn.
## First 6h — Watch Window
- Broker `/metrics`: `claudemesh_ws_connections` — alarm >500.
- Web + broker 429 rate: if >2% of traffic, raise limits.
- Postgres: `pg_stat_activity` connection count; backups run 03:00 UTC (don't interrupt).
- Traefik logs: TLS renewal errors, 5xx spikes.
- Signup funnel + mesh-create events every 30 min.
- Broker memory on VPS (`docker stats`): escalate at >80%.
## Common Failures — Responses
- **Broker OOM**: bump container memory in Coolify to 2GB, redeploy. Review connection leaks after.
- **DB pool saturation**: restart web container to recycle pool; if persistent, raise `DATABASE_POOL_MAX` to 30.
- **Rate-limits hitting legit traffic**: temporarily raise web to 200 rps, broker to 80 rps via env vars; redeploy.
- **Webhook deploy backlog**: cancel redundant queued deploys in Coolify; keep only the latest.
- **Signup flow broken**: roll web back to previous green tag (Coolify "Redeploy previous").
- **Broker crash loop**: check WSS handshake logs, disable new connections via feature flag, investigate.
## Who to Page
- **Broker bugs, WSS, protocol** → `claudemesh` peer.
- **Web UI, signup, dashboard** → `claudemesh-2` peer.
- **VPS, Traefik, DNS, Postgres, Coolify** → `ovhcloud-agutmou` peer.
- **DB schema / migrations** → `claudemesh` peer.
- **CLI / npm package** → `claudemesh` peer.
## T+24h: Post-Launch
- Pull metrics: peak connections, signup count, mesh count, 429 rate, p95 latency.
- Review rate-limit hits; adjust ceilings to real traffic shape.
- Triage GitHub issues opened during launch; tag v0.2 candidates.
- Retro with peers: biggest fire, biggest win, one fix for v0.2.
- Schedule v0.2 planning for T+72h.

Some files were not shown because too many files have changed in this diff Show More