182 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
dbea96960f fix(broker): plain text push messages, mesh slug in push label
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:27:22 +01:00
Alejandro Gutiérrez
a022da1998 fix(broker): show mesh slugs in /meshes + /status, remove all-meshes fallback
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
- /meshes and /status now show mesh slug names instead of truncated IDs
- meshSlug cached on connect and loaded from DB join on boot
- Remove dangerous fallback that connected to ALL meshes in email flow
- BridgeRow now includes optional meshSlug field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:24:55 +01:00
Alejandro Gutiérrez
5df2664bae feat(web): rewrite hero (pain-first) + streamline page + enterprise 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
Hero: 'Your Claude Code sessions work alone. claudemesh connects
them.' Three pain cards (context dies, teams relay by hand, setup
per developer). Solution paragraph focuses on shared wire with
E2E encryption.

Page: removed 5 redundant sections (Surfaces, LaptopToLaptop,
MeshVsMcp, MeetsYou, BeyondTerminal, DemoDashboard). Kept the
strongest: Hero, Features, WhatIsClaudemesh, Timeline, Pricing,
FAQ, CTA, MeshStats.

Pricing: added Enterprise tier with Contact sales → info@claudemesh.com.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:00:27 +01:00
Alejandro Gutiérrez
816c42feae docs: key points for landing page + outreach copy
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-10 01:56:40 +01:00
Alejandro Gutiérrez
4c0a417b7c docs: canonical pitch in founder's voice
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-10 01:54:59 +01:00
Alejandro Gutiérrez
e6962f1454 feat(web): /install route with server-side tracking
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
Restores the curl installer script at /install. Adds:
- In-memory fetch counter (visible in container logs)
- Server-side PostHog event 'install_script_fetched' with
  IP, user-agent, and referer (fire-and-forget)
- Console log per fetch for monitoring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:57:37 +01:00
Alejandro Gutiérrez
1d506f3ea5 fix(web): add libsodium-wrappers to serverExternalPackages
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
Missing from standalone output → invite creation crashes with
'Cannot find module libsodium-wrappers' in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:34:15 +01:00
Alejandro Gutiérrez
64266a75f7 fix(broker): plain text for email verification prompt (markdown parse error)
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
Masked email with asterisks broke Telegram Markdown bold syntax.
Use plain text for the code prompt message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:15:10 +01:00
Alejandro Gutiérrez
2710f354a9 fix(broker): correct libsodium import in email connect callback
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
Dynamic import returns module wrapper, need .default.ready then .default
for the actual sodium functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:09:32 +01:00
Alejandro Gutiérrez
6b55859d38 fix(broker): email connect searches userId + dashboardUserId + fallback
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
Members created by CLI don't have dashboardUserId set. Now searches
by both userId and dashboardUserId columns. Falls back to all meshes
if no member link found (bootstrap case for mesh owners).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:02:04 +01:00
Alejandro Gutiérrez
7d31cc6283 fix(broker): email connect creates bridge member with fresh keypair
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 member table has no secretKey column (by design - keys are local).
Email verification now generates a fresh ed25519 keypair and creates
a new bridge-specific member entry for each mesh the user belongs to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:54:16 +01:00
Alejandro Gutiérrez
0403cfeb76 chore(cli): bump to v0.9.2 with connect telegram command
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-09 14:00:45 +01:00
Alejandro Gutiérrez
d8e6900072 feat(broker): email verification flow for telegram /connect
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
Users can now type /connect in the bot → enter email → receive 6-digit
code → enter code → auto-connect to all meshes linked to that email.

Supports Resend and Postmark email providers via env vars.
Rate-limited to 5 code attempts, 10-min expiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:00:02 +01:00
Alejandro Gutiérrez
ed8dab8bd3 fix(web): update email to alex@mourente.ai + correct LinkedIn URL
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:44:48 +01:00
Alejandro Gutiérrez
dad51870d9 feat(broker): file upload recipient picker in telegram bridge
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
Instead of broadcasting files to all peers, the bot now uploads first
then shows an inline keyboard: individual peers, Everyone, or Keep private.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:43:55 +01:00
Alejandro Gutiérrez
a6af0f2154 security(broker): harden telegram bridge 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
- Validate JWT signature + expiry in /start (was only decoding, not verifying)
- Constant-time signature comparison in telegram-token.ts (prevent timing attacks)
- Rate limit /tg/token endpoint: 10 requests/hour per IP
- Grammy bot.catch() error handler (prevent unhandled rejections crashing broker)
- Cap WS reconnect attempts at 20 (prevent infinite retry loop)
- Expire stale pendingDMs entries (prevent memory leak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:20:59 +01:00
Alejandro Gutiérrez
0661e6223a fix(web): correct LinkedIn URL on about 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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:17:24 +01:00
Alejandro Gutiérrez
05e3c43e29 fix(web): scope webpack SVG loader to packages/ui only
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
Exclude app/ SVGs (icon.svg, opengraph) from @svgr/webpack —
Next.js metadata loader handles those. Only transform flag/logo
SVGs from packages/ui/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:01:00 +01:00
Alejandro Gutiérrez
e3fa6e6a5e feat(cli): register connect/disconnect telegram commands
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-09 12:44:32 +01:00
Alejandro Gutiérrez
17066b4f6c fix(web): add webpack SVG loader (TURBOPACK=0 prod builds)
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.rules only applies to turbopack. When building with
TURBOPACK=0 (required for Payload CMS), webpack has no SVG rule.
Icons.UnitedKingdom returns an object → React #130. Adding a
webpack config rule for @svgr/webpack fixes both bundler paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:44 +01:00
Alejandro Gutiérrez
8d1685e64d fix(broker): upsert telegram bridge on reconnect (duplicate key)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:33:02 +01:00
Alejandro Gutiérrez
bb28e16c7d fix(broker): increase healthcheck start-period, catch Grammy errors
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-09 11:14:44 +01:00
Alejandro Gutiérrez
ac59d2acfe fix(broker): correct bot username claudemeshbot (no underscore)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:08:00 +01:00
Alejandro Gutiérrez
0a1af84712 fix(web): skip sherif postinstall in Docker build
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:56:42 +01:00
Alejandro Gutiérrez
18dc29aba1 feat(web): timeline section — 66 releases, every feature shipped
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
Editorial timeline with vertical track, colored phase markers,
2-column feature grids per milestone. Shows v0.1→v0.8 evolution:
Foundation → Groups → Shared Intelligence → Files → Data Platform
→ Platform. Anchored by '66 npm releases. Every feature below is
in production today.' Dashed 'next' card at bottom for roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:52 +01:00
Alejandro Gutiérrez
795217093f fix(broker): wire telegram bridge boot + token endpoint into index.ts
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-09 10:49:56 +01:00
Alejandro Gutiérrez
61b0813924 fix(broker): add grammy dependency for telegram bridge
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-09 10:43:12 +01:00
Alejandro Gutiérrez
c10337ab9f chore: update lockfile for telegram bridge deps
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:38:53 +01:00
Alejandro Gutiérrez
126bbfeb2c feat(broker+cli): multi-tenant telegram bridge with 4 entry points
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
- DB: mesh.telegram_bridge table + migration
- Broker: telegram-bridge.ts (Grammy bot + WS pool + routing)
- Broker: telegram-token.ts (JWT connect tokens)
- Broker: POST /tg/token endpoint + bridge boot on startup
- CLI: claudemesh connect/disconnect telegram commands
- Spec: docs/telegram-bridge-spec.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:03:11 +01:00
Alejandro Gutiérrez
c914f2b7db chore: update lockfile for telegram package
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-09 03:01:27 +01:00
Alejandro Gutiérrez
a8b9348b36 feat(broker+cli): telegram bridge and file download proxy
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-09 02:57:02 +01:00
Alejandro Gutiérrez
c3dd4efe82 feat(cli): enforce context:fork via Agent tool instruction in prompts/get
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
Claude Code's MCP prompts path doesn't support the context field
natively. When a skill has context:"fork", prepend an instruction
telling the model to use the Agent tool with the specified agent
type and model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:16:00 +01:00
Alejandro Gutiérrez
a7d9ecab15 feat(broker): add cli-sync, member-api, jwt modules + DB schema updates
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
New broker endpoints for CLI auth sync flow (POST /cli-sync),
member profile management, and mesh settings. Includes JWT
verification for dashboard-issued sync tokens. DB schema adds
member profile fields and mesh policy columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:54:50 +01:00
Alejandro Gutiérrez
d263fe0f26 fix(cli): delay welcome notification for MCP init handshake
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
Welcome was silently dropped when sent before Claude Code's
notifications/initialized. Add 2s delay after WS connects to
ensure the MCP handshake is complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:25:10 +01:00
Alejandro Gutiérrez
3226493e6d fix(cli): catch unhandled rejection in background wirePushHandlers
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-09 01:15:09 +01:00
Alejandro Gutiérrez
4cb5a97512 perf(cli): instant MCP startup — WS connects in background
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
Move startClients() to run after server.connect(), not before.
MCP server is available to Claude Code in <0.5s instead of ~30s.
Tool handlers gracefully return errors until WS is ready.
Push event wiring happens in background callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:50 +01:00
Alejandro Gutiérrez
c080bc517f fix(web): stub all static asset extensions (.svg, .png, fonts) in ESM loader
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-09 01:09:38 +01:00
Alejandro Gutiérrez
471e88b3e6 fix(web): stub .scss/.sass/.less in addition to .css in ESM loader
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-09 00:52:39 +01:00
Alejandro Gutiérrez
c66e3adf67 fix(web): use absolute path for CSS stub loader in Docker
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-09 00:43:07 +01:00
Alejandro Gutiérrez
3f46a6657a fix(web): add CSS stub loader for Payload CMS route collection in Docker
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
Node ESM can't handle .css imports during Next.js route collection.
This loader intercepts .css resolutions and returns empty modules,
fixing the build for all Payload deps (richtext-lexical, react-image-crop, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:35:04 +01:00
Alejandro Gutiérrez
83ba1aa373 fix(web): restore serverExternalPackages for Payload + use --webpack for 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
Root cause: Next.js 16 defaults to Turbopack for builds, but Payload CMS's
richtext-lexical imports .css files that fail during route collection in
Node ESM context.

Fix: add @payloadcms/richtext-lexical and @payloadcms/next back to
serverExternalPackages so Next.js skips their internal imports during
route collection. Use --webpack explicitly since Turbopack production
builds are incompatible with Payload (payloadcms/payload#14786).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:26:06 +01:00
Alejandro Gutiérrez
7430e4ffe0 fix(web): header nav links → real pages (docs, blog, about, changelog)
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-09 00:24:33 +01:00
Alejandro Gutiérrez
d72e49b8fd fix(web): header GitHub link → claudemesh-cli repo
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-09 00:18:53 +01:00
Alejandro Gutiérrez
3f57944921 chore(cli): bump version to 0.9.0
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-09 00:01:58 +01:00
Alejandro Gutiérrez
b31aab8aeb feat(cli+broker): expose mesh skills as MCP prompts and skill:// resources
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 MCP server now declares prompts:{} and resources:{} capabilities.
Mesh skills auto-appear as /claudemesh:skill-name slash commands in Claude Code
via prompts/list+get, and as skill://claudemesh/{name} resources for the
upcoming MCP_SKILLS protocol. share_skill accepts optional metadata (when_to_use,
allowed_tools, model, context, agent) stored in the manifest jsonb column.
Change notifications sent on share/remove so Claude Code refreshes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:01:06 +01:00
Alejandro Gutiérrez
5db9842261 docs: add git deploy test result (45/45 pass)
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-08 20:08:09 +01:00
Alejandro Gutiérrez
81e520fdbb docs: update test results — 44/44 pass, CLI 0.8.0-0.8.9
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:54:53 +01:00
Alejandro Gutiérrez
26c4502277 fix(cli): display system push messages without decryption
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
System messages (watch_triggered, mcp_deployed, peer_joined, etc.)
have senderPubkey='system' with empty ciphertext. The push handler
now formats them as readable plaintext instead of failing to decrypt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:12:49 +01:00
Alejandro Gutiérrez
bfc62b9a72 fix(cli): display system push messages without decryption
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
System messages (watch_triggered, mcp_deployed, peer_joined, etc.)
have senderPubkey='system' with empty ciphertext. The push handler
now formats them as readable plaintext instead of failing to decrypt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:51:12 +01:00
Alejandro Gutiérrez
f8c6f9ae74 feat(broker): add test endpoints for url watch validation
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-08 18:37:23 +01:00
Alejandro Gutiérrez
3497700fad feat: url watch — broker polls URLs, notifies on change
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-08 18:29:43 +01:00
Alejandro Gutiérrez
2c156f832e docs: add test results for mesh services platform (37/37 pass)
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-08 16:37:47 +01:00
Alejandro Gutiérrez
4ee810242d fix(broker): restore services in failed/crashed/restarting states too
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-08 16:30:15 +01:00
Alejandro Gutiérrez
b6224c4186 fix(broker): sync with runner on boot instead of re-deploying
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
Boot restore now checks runner /health to see what's already running,
then updates DB status to match. Fixes the bug where broker restart
marked running services as 'failed' because it tried to re-deploy
without shared source volume.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:26:43 +01:00
Alejandro Gutiérrez
4c385a16cc fix(runner): use python -m for Python MCPs instead of CLI binary
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:38:31 +01:00
Alejandro Gutiérrez
4ae6a86bf6 fix(runner): retry MCP init for slow Python startup
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-08 15:37:04 +01:00
Alejandro Gutiérrez
c327c282e3 fix(runner): install mcp[cli] extras for Python MCPs
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-08 15:35:16 +01:00
Alejandro Gutiérrez
e645455b22 fix(runner): run Python venv binaries directly, not via node
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-08 15:34:29 +01:00
Alejandro Gutiérrez
45505a1635 fix(runner): fix uvx variable scoping bug
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-08 15:33:51 +01:00
Alejandro Gutiérrez
17e6361d64 fix(runner): uv venv --clear for redeployments
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:31:52 +01:00
Alejandro Gutiérrez
528e7e21b1 fix(runner): use uv pip install for Python venv
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-08 15:31:13 +01:00
Alejandro Gutiérrez
7b875de301 feat(runner): add uvxPackage source type for Python MCPs
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-08 15:30:30 +01:00
Alejandro Gutiérrez
8a3c96dc7c fix(runner): prefer package-matching binary over utility bins
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:28:50 +01:00
Alejandro Gutiérrez
b0634b829c fix(runner): set GIT_TERMINAL_PROMPT=0 for non-interactive clone
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-08 15:27:10 +01:00
Alejandro Gutiérrez
2bd388a5e2 fix(runner): add missing writeFileSync import
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-08 13:22:12 +01:00
Alejandro Gutiérrez
71c0767a1b feat: runner accepts git/npx sources, broker delegates extraction
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
Runner /load now accepts gitUrl, npxPackage, or sourcePath. It handles
git clone and npm install internally. Broker no longer needs shared
volume for source extraction — just tells the runner what to fetch.

CLI mesh_mcp_deploy now supports npx_package as a third source type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:18:25 +01:00
Alejandro Gutiérrez
6a3f087209 fix(runner): add unzip for bun install in Dockerfile
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-08 13:08:27 +01:00
Alejandro Gutiérrez
873f588057 feat: runner container + broker deploy pipeline
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
- apps/runner/: Dockerfile (node22 + python3 + uv + bun) + supervisor.mjs
  (HTTP API for load/call/unload/health)
- docker-compose: runner service with shared services-data volume
- Broker mcp_deploy: git clone or zip extract → runner /load → MCP spawn
- Broker mcp_call: routes managed services to runner via HTTP, falls back
  to live-proxy for peer-hosted servers
- RUNNER_URL env var for broker → runner communication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:06:43 +01:00
Alejandro Gutiérrez
070a3b7422 feat(broker): encrypt env vars at rest, restore on reboot
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
- broker-crypto.ts: AES-256-GCM encrypt/decrypt with BROKER_ENCRYPTION_KEY
- mcp_deploy stores env as _encryptedEnv in mesh.service.config (no plaintext in DB)
- boot restore: decrypts _encryptedEnv and re-spawns services via service-manager
- auto-generates ephemeral key if BROKER_ENCRYPTION_KEY not set (logs warning)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:25:48 +01:00
Alejandro Gutiérrez
75ca892ea7 feat(cli): vault_get + deploy-time vault resolution
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
- Add vault_get wire message to fetch encrypted entries for client-side
  decryption
- Deploy handler resolves $vault: refs: fetches encrypted entries from
  broker, decrypts with mesh keypair locally, sends resolved env over TLS
- File-type vault entries encoded as __vault_file__:path:base64 for
  runner-side extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:16:46 +01:00
Alejandro Gutiérrez
a90046a8e3 fix(cli): e2e encrypt vault entries with libsodium
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-08 12:10:23 +01:00
Alejandro Gutiérrez
02a165dd76 feat(cli): add --resume and --continue flags to 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
claudemesh launch now supports:
  --resume <id> / -r  — resume a previous Claude Code session
  --continue / -c     — continue the most recent conversation

When resuming, skips generating a new session ID so the mesh peer
identity persists. The detectClaudeSessionId() fallback in ws/client.ts
picks up the existing session UUID from the .jsonl file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:57:24 +01:00
Alejandro Gutiérrez
52393429f9 feat(cli): use Claude Code session ID for mesh peer identity
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 launch now generates a UUID and passes it to claude via
--session-id flag + CLAUDEMESH_SESSION_ID env var. The MCP server
reads this and sends it in the hello handshake.

Fallback: when launched without claudemesh launch (e.g., claude --resume),
detectClaudeSessionId() scans ~/.claude/projects/ for the most recent
.jsonl file and extracts the session UUID from the filename.

Benefits:
- Broker detects reconnections (same session = restore state)
- Multiple peers in same project dir get unique identities
- Session identity persists across --resume

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:38:44 +01:00
Alejandro Gutiérrez
9474d985ae fix(cli): add missing tool call handlers for vault + service tools
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 Wave 3I handlers (vault_set, vault_list, vault_delete, mesh_mcp_deploy,
mesh_mcp_undeploy, mesh_mcp_update, mesh_mcp_logs, mesh_mcp_scope,
mesh_mcp_schema, mesh_mcp_catalog, mesh_skill_deploy) were lost during
the re-apply phase. Tools were registered in tools/list but returned
"Unknown tool" because the switch cases in server.ts were missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:25:18 +01:00
Alejandro Gutiérrez
643c808685 docs(web): 2-command onboarding — install + launch --join
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
Simplify getting-started to 2 steps: npm install + launch --join.
Remove "claudemesh install" section, update join page to show
launch --join as the primary flow, update invite format examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:13:21 +01:00
Alejandro Gutiérrez
2c24f667f9 refactor(web): remove install script, simplify onboarding to 3 steps
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Drop /install route (curl|bash script). Install is just `npm i -g
claudemesh-cli`. Update hero, FAQ, getting-started, and join flow to
reflect the simplified 3-step onboarding: install → join → launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:09:17 +01:00
Alejandro Gutiérrez
b0113913f2 chore: bump cli to 0.8.0
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-08 10:54:16 +01:00
Alejandro Gutiérrez
e1cafa54b3 feat: mesh services platform — deploy MCP servers, vaults, scopes
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
Add the foundation for deploying and managing MCP servers on the VPS
broker, with per-peer credential vaults and visibility scopes.

Architecture:
- One Docker container per mesh with a Node supervisor
- Each MCP server runs as a child process with its own stdio pipe
- claudemesh launch installs native MCP entries in ~/.claude.json
- Mid-session deploys fall back to svc__* dynamic tools + list_changed

New components:
- DB: mesh.service + mesh.vault_entry tables, mesh.skill extensions
- Broker: 19 wire protocol types, 11 message handlers, service catalog
  in hello_ack with scope filtering, service-manager.ts (775 lines)
- CLI: 13 tool definitions, 12 WS client methods, tool call handlers,
  startServiceProxy() for native MCP proxy mode
- Launch: catalog fetch, native MCP entry install, stale sweep, cleanup,
  MCP_TIMEOUT=30s, MAX_MCP_OUTPUT_TOKENS=50k

Security: path sanitization on service names, column whitelist on
upsertService, returning()-based delete checks, vault E2E encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:53:03 +01:00
Alejandro Gutiérrez
a4f2e0aa81 feat(web): mesh structure section (tree + coordination patterns)
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
Shows the full hierarchy: Organization → Mesh → @groups → Peers
with live state + memory. Six coordination patterns below with
code snippets: lead-gather, delegation, voting, chain review,
broadcast, targeted views. Footer: 'All patterns are conventions
in system prompts. The broker routes; Claude coordinates.'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:20:31 +01:00
Alejandro Gutiérrez
cbcde4d910 feat(web): capability stack diagram below the wire
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
Shows the 12 capability categories that flow through the mesh:
messages, groups, state, memory, files, SQL, vectors, graph,
tasks, context, streams, scheduled. Each with a mono icon tag
and one-line description. Anchored by '43 MCP tools, 5
persistence backends' footer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:14:38 +01:00
Alejandro Gutiérrez
495c234159 fix(web): enable turbopack + payload by unbundling richtext-lexical
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 CSS import error was caused by richtext-lexical being in
serverExternalPackages — Node can't require .css files. Removing
it lets Turbopack bundle it (handling CSS natively). Other payload
packages stay external (they don't import CSS).

Restores turbopack as the default production bundler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:38:56 +01:00
Alejandro Gutiérrez
42c1d02f5e docs: add game architecture vision — NPCs as data, AI on demand
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
NPCs are mesh data (skills, memory, state), not peers. One API call
per interaction, 3 coordinator peers per faction. Game connector
assembles context from mesh and calls any LLM on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:30:13 +01:00
Alejandro Gutiérrez
a33c925216 docs: add simulation controller SDK spec, replace spatial topology
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 mesh is the communication fabric, not the simulation engine.
SimController pattern: external controller drives tick loop, computes
visibility, sends observations to peers, collects actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:38:43 +01:00
Alejandro Gutiérrez
6ab3fbbea3 fix(web): fix getting-started metadata export, use TURBOPACK=0 env for 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
- generateMetadata instead of metadata (getMetadata returns a function)
- Use TURBOPACK=0 env prefix instead of --no-turbopack flag (not recognized in Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:37:10 +01:00
Alejandro Gutiérrez
26adbafde2 fix(web): remove --no-turbopack from build script (Docker uses ENV TURBOPACK=0)
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
The --no-turbopack flag isn't recognized when Next.js runs inside the
Docker builder stage. The Dockerfile already sets ENV TURBOPACK=0 which
achieves the same effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:29:20 +01:00
Alejandro Gutiérrez
13e8ce07ac chore: bump cli to 0.7.1
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:22:57 +01:00
Alejandro Gutiérrez
5398ca6833 feat: make MCP server registrations persistent across peer disconnects
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
Persistent MCP servers (opt-in via `persistent: true`) survive host
disconnects — they appear as offline in mcp_list and auto-restore when
the host reconnects. Ephemeral servers (default) still clean up on
disconnect. Offline servers return a clear error on mcp_call with
time-since-disconnect info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:22:06 +01:00
Alejandro Gutiérrez
56b1cc0756 docs: split vision into changelog + clean 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
changelog-20260407.md: full implementation details for 21 features
vision-20260407.md: slimmed to shipped summary + remaining items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:20:55 +01:00
Alejandro Gutiérrez
fc8a7edc23 feat: persist peer session state across disconnects ("welcome back" on reconnect)
Save groups, profile, visibility, summary, display name, and cumulative
stats to a new mesh.peer_state table on disconnect. On reconnect (same
meshId + memberId), restore them automatically — hello groups take
precedence over stored groups if provided. Broadcast peer_returned
system event with last-seen time and summary to other peers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:20:20 +01:00
Alejandro Gutiérrez
e09671cdcb feat: broadcast system notifications on MCP server register/unregister
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Peers now receive [system] notifications when MCP servers join or
leave the mesh, with tool names and hosting peer info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:16:58 +01:00
Alejandro Gutiérrez
32fc4a0c98 fix: align connector-slack and connector-telegram deps with workspace versions
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
Sherif enforces consistent dependency versions across the monorepo.
The connectors used ^8.0.0 for ws and @types/ws while the rest used
exact 8.20.0 / 8.5.13. Also sorted dependencies alphabetically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:16:19 +01:00
Alejandro Gutiérrez
b315b31cc9 docs: add peer session persistence and MCP notification to vision
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:15:49 +01:00
Alejandro Gutiérrez
21cb6efced docs: mark all implemented vision items with commit refs
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
17 of 22 items done, 2 partial. Updated all section headers and
added implementation notes with commits and timestamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:12:37 +01:00
Alejandro Gutiérrez
125b576e2c chore: update pnpm lockfile for connector-slack and connector-telegram deps
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 lockfile was stale — connector-slack/package.json added 7 deps that
weren't reflected in pnpm-lock.yaml, causing frozen-lockfile builds to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:12:13 +01:00
Alejandro Gutiérrez
3641618391 docs(mcp): add file access decision guide to instructions
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
Teaches AI when to use filesystem (local), read_peer_file (remote
<1MB), or share_file (persistent, no size limit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:09:09 +01:00
Alejandro Gutiérrez
a92cf6b629 feat: hint AI to use filesystem for local peer file reads
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
When read_peer_file targets a local peer (same hostname), prepend a
hint with the direct filesystem path. Still executes the relay as
fallback — AI learns the shortcut without being blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:06:27 +01:00
Alejandro Gutiérrez
2c9c8c7b6c feat: add hostname to hello + local/remote peer locality detection
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
Peers report os.hostname() in the hello handshake. list_peers shows
[local] or [remote] tag per peer. MCP instructions teach AI to read
local peers' files directly via filesystem instead of relay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:05:46 +01:00
Alejandro Gutiérrez
98fda20ab6 chore: bump cli to 0.7.0
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:01:09 +01:00
Alejandro Gutiérrez
025a53a70c docs: update vision — 17 of 23 items implemented, add telemetry idea
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:00:37 +01:00
Alejandro Gutiérrez
b55cf269a4 feat: implement inbound webhooks for external service integration
Add the webhook handler module (webhooks.ts) that verifies secrets
against the mesh.webhook table and broadcasts incoming HTTP POST
payloads to all connected mesh peers. This completes the webhook
feature whose schema, types, WS CRUD handlers, and CLI tools were
added in the previous commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:58:01 +01:00
Alejandro Gutiérrez
504111c50c feat: add read_peer_file and list_peer_files MCP tools
Wire up MCP tool handlers for the peer file sharing relay. Peers can
now read files and list directories from other peers' local filesystems
through the mesh broker. Includes name-to-pubkey resolution, base64
decode, and instructions table update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:56:42 +01:00
Alejandro Gutiérrez
05d9b56f28 feat: implement simulation clock with configurable time multiplier
Broker-driven clock that broadcasts periodic heartbeat ticks to all
peers in a mesh. Speed is configurable from x1 (real-time, 60s ticks)
to x100 (600ms ticks) for load testing simulations. Auto-pauses when
the last peer disconnects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:55:14 +01:00
Alejandro Gutiérrez
c8cb1e3ea5 feat: implement mesh skills catalog — peers publish and discover reusable instructions
Adds share_skill, get_skill, list_skills, and remove_skill across the full
stack (Drizzle schema, broker CRUD + WS handlers, CLI client methods, MCP
tools). Skills are mesh-scoped, unique by name, and searchable via ILIKE
on name/description/tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:55:03 +01:00
Alejandro Gutiérrez
86a258301f feat: implement signed hash-chain audit log for mesh events
Add tamper-evident audit logging where each entry includes a SHA-256
hash of the previous entry, forming a verifiable chain per mesh.
Events tracked: peer_joined, peer_left, state_set, message_sent
(never logs message content). New WS handlers: audit_query for
paginated retrieval, audit_verify for chain integrity verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:54:57 +01:00
Alejandro Gutiérrez
7e102a235b feat: add @claudemesh/sdk standalone client library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:53:46 +01:00
Alejandro Gutiérrez
5563f90733 feat: add @claudemesh/sdk package for non-Claude-Code clients
Standalone TypeScript SDK that any process can use to join a mesh and
send/receive messages. Implements the same WS protocol and libsodium
crypto_box encryption as the CLI, with an EventEmitter-based API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:53:22 +01:00
Alejandro Gutiérrez
b3b9972e60 feat: add peer stats reporting (messages, tool calls, uptime, errors)
Peers self-report resource usage via set_stats; stats visible in
list_peers responses and the new mesh_stats MCP tool. CLI auto-reports
every 60s and tracks messagesIn/Out, toolCalls, uptime, and errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:52:26 +01:00
Alejandro Gutiérrez
fe9285351b feat: add Telegram connector package for mesh-to-chat bridging
Introduces @claudemesh/connector-telegram — a standalone bridge process
that joins a mesh as peerType: "connector" and relays messages
bidirectionally between a Telegram chat and mesh peers via long polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:52:00 +01:00
Alejandro Gutiérrez
08e289a5e3 feat: implement mesh MCP proxy — dynamic tool sharing between peers
Peers can register MCP servers with the mesh and other peers can invoke
those tools through the existing claudemesh connection without restarting.

Broker: in-memory MCP registry with mcp_register/unregister/list/call
handlers, call forwarding to hosting peer with 30s timeout, and automatic
cleanup on peer disconnect.

CLI: mcpRegister/mcpUnregister/mcpList/mcpCall client methods, inbound
mcp_call_forward handler, and 4 new MCP tools (mesh_mcp_register,
mesh_mcp_list, mesh_tool_call, mesh_mcp_remove).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:50:54 +01:00
Alejandro Gutiérrez
7d432b3aaa feat(web): add state timeline and resource panels to live mesh dashboard
Two new panels below the existing peer graph + live stream grid:
- StateTimelinePanel: vertical timeline of audit events and presence
  status changes, auto-scrolling, sorted newest-first
- ResourcePanel: 2x2 card grid showing live peers, envelopes by
  priority, audit event breakdown, and session status

Both share the same TanStack Query cache key as the existing panels
(no extra API calls). Matches the --cm-* dark terminal aesthetic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:50:18 +01:00
Alejandro Gutiérrez
b0dc538119 feat(cli): nudge user to join a mesh when install finds none
After MCP registration and hooks setup, `claudemesh install` now checks
the config for joined meshes. If empty, it prints actionable guidance
(join command + dashboard URL) instead of the generic "Next:" line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:49:37 +01:00
Alejandro Gutiérrez
27c9d2a02c docs: add peer visibility, spatial topology, and public profiles to vision
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:41:06 +01:00
Alejandro Gutiérrez
87e0d0004d docs: mark 5 vision items as implemented with commit refs
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:37:22 +01:00
Alejandro Gutiérrez
dba0fb7b33 chore: bump cli to 0.6.9
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:36:03 +01:00
Alejandro Gutiérrez
72be651ca8 feat(cli): add --cron flag to remind command
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:34:40 +01:00
Alejandro Gutiérrez
db2bf3ea06 docs(protocol): add missing message types and new features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:34:15 +01:00
Alejandro Gutiérrez
e87380775f feat: add persistent cron-based recurring reminders
Replace in-memory-only setTimeout scheduling with a DB-backed system
that survives broker restarts. Adds:

- `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE
  for zero-downtime deploys)
- Minimal 5-field cron parser (no dependencies) with next-fire-time
  calculation for recurring entries
- On broker boot, all non-cancelled entries are loaded from PostgreSQL
  and timers re-armed automatically
- CLI `schedule_reminder` MCP tool accepts optional `cron` expression
- CLI `remind` command accepts `--cron` flag
- One-shot reminders remain backward compatible — no cron field = same
  behavior as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:33:47 +01:00
Alejandro Gutiérrez
58ba01f20f fix(cli): sync CLAUDEMESH_TOOLS with current tool definitions and sort alphabetically
Add 4 missing tools (cancel_scheduled, grant_file_access, list_scheduled,
schedule_reminder) and sort the array alphabetically for maintainability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:33:02 +01:00
Alejandro Gutiérrez
59332dc47d feat(web): add peer graph visualization to live mesh dashboard
Renders peers as SVG nodes in a radial layout with animated edges
showing real-time message traffic. Shares the same TanStack Query
cache as LiveStreamPanel (same queryKey). Side-by-side on desktop,
stacked on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:32:41 +01:00
Alejandro Gutiérrez
f34b8fbc6b docs(cli): improve --help text for clarity, concision, and consistency
Rewrite all command and argument descriptions in index.ts to follow
imperative mood, omit filler, use backtick-formatted values, and
surface key behaviors (e.g. launch spawns Claude Code with MCP,
remind supports list/cancel subactions, send accepts @group and *).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:55 +01:00
Alejandro Gutiérrez
79525af42e fix(broker): remove cron example from JSDoc that broke TSC
The "0 */2 * * *" cron example inside a /** comment caused TSC to
parse */ as end-of-comment, producing syntax errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:31 +01:00
Alejandro Gutiérrez
69e93d4b8c feat(cli): add mesh templates and claudemesh create command
Predefined mesh configurations (dev-team, research, ops-incident,
simulation, personal) let users bootstrap meshes with groups, roles,
state keys, and system prompt hints. Templates are bundled at build
time via Bun's JSON import support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:12 +01:00
Alejandro Gutiérrez
810f372d1c feat: add peer metadata (peerType, channel, model) and cwd to peer list
Extend the WS hello handshake with optional peerType, channel, and model
fields so peers can advertise what kind of client they are. The broker
stores these in-memory on PeerConn and returns them (along with cwd) in
the peers_list response. CLI peers command and MCP list_peers tool now
display the new metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:30:04 +01:00
Alejandro Gutiérrez
453705a4e1 feat: broadcast system notifications on peer join/leave
When a peer connects or disconnects, the broker now broadcasts a
system push (subtype: "system") to all other peers in the same mesh.
The CLI formats these as [system] channel notifications so AI sessions
can react to topology changes without polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:28:49 +01:00
Alejandro Gutiérrez
5cb4cc4fe7 feat(web): update landing page copy for full feature surface, add getting started + mesh vs MCP
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 copy was stuck at the v0.1 feature set (messaging + state + memory + groups).
The CLI now ships 43 MCP tools across 5 persistence backends. This commit brings the site
copy in sync with what's actually built.

Changes:
- Hero, features, pricing, FAQ, CTA, footer: reflect 43 tools, files, SQL, vectors, graphs
- Features section: expanded from 4 tabs to 7 (added Files, Database, Vectors)
- New /getting-started page: full install guide with correct 4-step flow
- New Mesh vs MCP section: side-by-side diagrams + 8-row comparison table
- Fix: install-toggle on /join page had `npx claudemesh@latest init` (init doesn't exist)
  → replaced with `curl -fsSL https://claudemesh.com/install | bash`
- Navigation: added Getting Started to header, footer, hero link
- COPY.md synced with all 6 capability areas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:58:22 +01:00
Alejandro Gutiérrez
eeac47c360 chore: bump cli to 0.6.8
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:39:21 +01:00
Alejandro Gutiérrez
0bb9d71a26 feat: merge schedule_reminder + send_later, add subtype reminder
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
- Merge send_later into schedule_reminder (optional `to` param — omit for self-reminder)
- Add subtype?: "reminder" to WSPushMessage, WSScheduleMessage, ScheduledEntry, InboundPush
- Broker handleSend now accepts optional subtype and injects into push envelope
- deliver closure passes sm.subtype so reminders surface correctly
- MCP channel meta includes subtype field; formatPush tags [REMINDER] in check_messages
- MCP server instructions document subtype and schedule_reminder/list_scheduled/cancel_scheduled
- client.scheduleMessage accepts isReminder flag, sends subtype: "reminder" on wire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:38:41 +01:00
Alejandro Gutiérrez
3ff7a61e3f chore: update pnpm lockfile after citty + scheduled message deps
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:58:22 +01:00
Alejandro Gutiérrez
e76ade64d2 feat: scheduled messages — schedule_reminder, send_later, list_scheduled, cancel_scheduled
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
- Broker: schedule/list_scheduled/cancel_scheduled WS message types + in-memory delivery
- Client: scheduleMessage(), listScheduled(), cancelScheduled() with resolver Map pattern
- MCP: schedule_reminder, send_later, list_scheduled, cancel_scheduled tools
- CLI: claudemesh remind <msg> --in 2h | --at 15:00 | list | cancel <id>
- Types: WSScheduleMessage, WSScheduledAckMessage, WSScheduledListMessage, WSCancelScheduledAckMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:53:42 +01:00
Alejandro Gutiérrez
59848f0d3e chore(cli): v0.6.6 — correlation ID refactor (resolver Maps + _reqId)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-04-07 14:31:29 +01:00
Alejandro Gutiérrez
d0fa1c028f fix(broker): echo _reqId in all WS responses for correlation ID routing
Extract _reqId from incoming WS messages and include it in every direct
response sendToPeer call and sendError call. Clients can now match
responses to requests by ID instead of relying on FIFO ordering.
Old clients without _reqId are unaffected (field simply omitted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:28:30 +01:00
Alejandro Gutiérrez
8f925d9a9e fix(cli): correlation ID refactor — resolver Maps with _reqId + FIFO fallback
Replace all 22 resolver Array<fn> patterns with Map<reqId, {resolve, timer}>.
Outgoing messages now include _reqId; on response the broker's echoed _reqId
is used for exact matching, with FIFO fallback for brokers that don't echo it.
Add makeReqId() helper and resolveFromMap() utility. Error propagation block
updated to iterate Maps and pop the oldest entry across all queues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:25:51 +01:00
Alejandro Gutiérrez
4ce1034dcd chore(cli): v0.6.5 — all bug fixes batch
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-07 13:12:29 +01:00
Alejandro Gutiérrez
e26a36e543 fix(broker): vector_stored type, set_state no-resp, subscribe ack
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
- vector_store sends {type:"vector_stored",id}; wrapped in try/catch
- set_state no longer sends state_result (fire-and-forget)
- subscribe sends {type:"subscribed",stream} confirmation
- remove broken myPresence lookup in mesh_info
- add WSVectorStoredMessage + WSSubscribedMessage to types union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:08:06 +01:00
Alejandro Gutiérrez
60c74d9463 fix(broker): shareContext stable upsert key + createStream atomic upsert
- shareContext: adds optional memberId param; when provided, upserts on
  (meshId, memberId) instead of (meshId, presenceId) — prevents stale
  context rows accumulating on every reconnect. Falls back to presenceId
  for legacy/anonymous connections. Also refreshes presenceId on update
  so it stays current.
- schema: adds member_id column + unique index context_mesh_member_idx
  on mesh.context table; new migration 0013_context-stable-member-key.sql.
- index.ts call site updated to pass conn.memberId as the stable key.
- createStream: replaces SELECT-then-INSERT TOCTOU race with atomic
  INSERT ... ON CONFLICT DO NOTHING RETURNING, followed by SELECT on miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:58 +01:00
Alejandro Gutiérrez
6fba9bd4eb feat(cli): fix field mismatches + error propagation
- claim_task/complete_task: send taskId not id
- graph_result: read msg.records not msg.rows
- message_status: try all mesh clients, not only first
- broker: omit state_result for set_state (fixes get_state cross-contamination)
- error handler: unblock first pending resolver on unmatched broker errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:25 +01:00
Alejandro Gutiérrez
5bcc1fe323 chore(cli): bump to v0.6.4 — fix get_file sealedKey bug
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-07 12:57:20 +01:00
Alejandro Gutiérrez
e70f0ed1ff fix(broker/cli): e2e get_file owner sealedKey bug
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
broker: owner also fetches sealedKey from mesh.file_key (not skipped),
  only non-owners are blocked when key is missing
cli: explicit error when encrypted file has no sealedKey (no silent raw download)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:56:36 +01:00
Alejandro Gutiérrez
5f696f47ea feat(cli): v0.6.3 — e2e file crypto module + encrypted share_file
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
- add crypto/file-crypto.ts: encryptFile, decryptFile, sealKeyForPeer, openSealedKey
- update share_file: when to= set, encrypts file + seals key per recipient
- update get_file: decrypts if encrypted + sealedKey present
- add grant_file_access tool: re-seals Kf for a new peer without re-upload
- add getSessionPubkey/getSessionSecretKey getters on BrokerClient
- add grantFileAccess WS method on BrokerClient
- bump version to 0.6.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:53:13 +01:00
Alejandro Gutiérrez
ccb9fb2a68 feat(broker/db): e2e file encryption schema + db functions
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
- add mesh.file_key table (fileId, peerPubkey, sealedKey, grantedByPubkey)
- add encrypted + ownerPubkey columns to mesh.file
- export insertFileKeys, getFileKey, grantFileKey from broker.ts
- update uploadFile/getFile/listFiles to include encrypted/ownerPubkey
- migration 0012_add-file-encryption applied to prod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:43:57 +01:00
Alejandro Gutiérrez
898c061089 feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:33:39 +01:00
Alejandro Gutiérrez
f7a6559429 feat(broker): add E2E file encryption to HTTP upload and WS handlers
- parse x-encrypted/x-owner-pubkey/x-file-keys headers in handleUploadPost
- pass encrypted and ownerPubkey to uploadFile, call insertFileKeys after
- get_file: fetch sealedKey for non-owners, block if missing, include in response
- list_files: include encrypted field per file
- add grant_file_access WS handler so owners can seal keys for peers
- update types.ts with new message interfaces and union members

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:32:46 +01:00
Alejandro Gutiérrez
579d0c3d3e chore: bump version to 0.6.0
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:21:03 +01:00
Alejandro Gutiérrez
190f5a958e refactor(cli): migrate to citty — --help generated from flag definitions
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
Replace manual switch + HELP string with citty defineCommand/runMain.
Flag definitions in index.ts are now the single source of truth for
--help output. Remove parseArgs() from launch.ts; accept citty-parsed
flags + rawArgs (-- passthrough to claude preserved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:19:16 +01:00
Alejandro Gutiérrez
03661e1b68 docs(cli): expand --help with all launch flags, groups hierarchy, env vars
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 Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:16:04 +01:00
Alejandro Gutiérrez
d451fc296e feat: hierarchical group routing + role wiring
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
broker: expand member groups to ancestor paths at drain time (pull model)
- @flexicar message reaches peers in @flexicar/core, @flexicar/output, etc.
- Resolved at drainForMember — no DB changes, fully backward-compatible
- Any depth: flexicar/team/backend also matches @flexicar and @flexicar/team

cli: wire --role all the way through to session config + env
- Config.role field added
- launch.ts stores role in sessionConfig, passes CLAUDEMESH_ROLE env var
- mcp/server.ts includes role in identity string
- manager.ts auto-joins groups from config on WS connect (--groups flag now works)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:09:37 +01:00
Alejandro Gutiérrez
3da5d71275 fix(broker): fix share_file DB insert failures
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
- Normalise tags to Array before Drizzle insert (PgArray mapper calls
  .map() and throws if value is not a standard JS Array)
- Use uploadedByName instead of uploadedByMember FK — the X-Member-Id
  header carries the mesh slug, not a mesh.member primary key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:56:43 +01:00
Alejandro Gutiérrez
cdf335f609 fix(broker): fix MINIO_USE_SSL env coercion
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
z.coerce.boolean() treats any non-empty string as true, so MINIO_USE_SSL="false" → true.
Switch to explicit enum+transform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:38:06 +01:00
Alejandro Gutiérrez
0cd16ff358 fix: exclude sender only for broadcasts, not direct messages
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 sender exclusion filter (excludeSenderSessionPubkey) was blocking
delivery of ALL messages from the sender, including direct messages
to other peers. Now only excludes on broadcast (target_spec = '*').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:34:09 +01:00
Alejandro Gutiérrez
3e9707276d fix: add diagnostic logging to maybePushQueuedMessages
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 18:21:29 +01:00
Alejandro Gutiérrez
82cfee315c fix: v0.5.9 — mesh_info returns correct display name
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 18:10:30 +01:00
Alejandro Gutiérrez
ab08be04a5 feat(cli): v0.5.8 — welcome notification on connect
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 18:07:08 +01:00
Alejandro Gutiérrez
ee585a8370 fix(cli): v0.5.7 — event loop keepalive for stdout flush
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
Node.js stdout to a pipe is buffered. Without periodic event loop
activity, WS callback → server.notification() → stdout.write() may
not flush until the next I/O event. A 1s setInterval (NOT unref'd)
keeps the event loop ticking so notifications flush immediately.

This is why claude-intercom worked: its 1s HTTP poll kept the event
loop active as a side effect. Claudemesh's passive WS listener let
the event loop settle, causing stdout to buffer indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:48:41 +01:00
Alejandro Gutiérrez
1f078bf0c8 fix(web): --no-turbopack for prod build (payload CSS)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:24 +01:00
Alejandro Gutiérrez
2372032a68 fix(cli): v0.5.6 — fix ping_mesh self-send + add diagnostics
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 17:32:03 +01:00
Alejandro Gutiérrez
a70c5fd124 feat(cli): v0.5.5 — ping_mesh diagnostic tool
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
Sends test messages to self through the full pipeline per priority
and measures round-trip timing. Reports send→ack and send→receive
latency. Detects broker priority gating (status=working holds next/low).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:27:00 +01:00
Alejandro Gutiérrez
5c62d287cf fix(cli): v0.5.4 — revert to event-driven push, add Claude Code integration spec
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
Revert poll-based drain (v0.5.2 overcorrection). Claude Code source
confirms notifications are processed event-driven via React
useEffect, not polled. The WS onPush → server.notification() path
is correct.

Added section 13 to SPEC.md documenting the full Claude Code
notification pipeline, feature gates, priority gating, and common
push delivery issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:04:05 +01:00
Alejandro Gutiérrez
9ae378c2e3 fix(cli): v0.5.3 — add push delivery debug logging
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 16:49:49 +01:00
Alejandro Gutiérrez
7381738f0b fix(web): disable turbopack for prod build (payload CSS 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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:46:28 +01:00
Alejandro Gutiérrez
8c6b0c0e07 fix(cli): v0.5.2 — poll-based push delivery (1s interval)
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 WS onPush→notification with timer-based buffer drain.
The old claude-intercom used 1s polling and worked reliably.
WS async callbacks may not flush stdio properly for MCP
notifications. Polling on a timer ensures consistent delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:33:26 +01:00
Alejandro Gutiérrez
ec9626503c fix(web): force-dynamic on payload admin page (build CSS error)
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 16:16:21 +01:00
Alejandro Gutiérrez
820ec085b2 feat(cli): v0.5.1 — message modes (push/inbox/off)
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
--inbox: count-only notifications, no content in context
--no-messages: tools only, zero prompt injection risk
Default: push (real-time, current behavior)

Wizard shows mode picker when no flag provided.
MCP instructions tell Claude its current mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:53:41 +01:00
Alejandro Gutiérrez
9e6f6d7bc9 docs: add message modes + shared MCPs spec
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
Message modes: push/inbox/off for controlling prompt injection risk.
Shared MCPs: mesh-level MCP servers proxied through the broker —
install once, every peer has access. Full architecture, DB schema,
WS protocol, credential isolation, resource limits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:52:43 +01:00
Alejandro Gutiérrez
7194e7d28e chore: regenerate lockfile from scratch
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 15:47:26 +01:00
Alejandro Gutiérrez
0b4e389f2b feat(web): restore payload CMS (cuidecar pattern + importMap)
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 14:30:16 +01:00
Alejandro Gutiérrez
7a5f786e0c chore: sync lockfile
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
2026-04-06 14:25:19 +01:00
Alejandro Gutiérrez
10e5fdcfd1 feat(web): rewrite landing for v0.3 product (groups, state, memory)
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
Hero: sessions form a team with groups, state, memory — not just
messaging. Features: 4 tabs with real CLI code (groups, state,
memory, coordination patterns). Use cases: team sprint with 5
agents, new-hire knowledge transfer via recall(), deploy-frozen
via shared state. All match the shipped spec (v0.3.0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:03:10 +01:00
Alejandro Gutiérrez
cc6e56aef9 docs: final spec — vectors, graph, context, tasks, streams, databases
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 vision: claudemesh provisions shared infrastructure per mesh.
Peers share messages, state, memory, files, vector embeddings,
entity graphs, session context, tasks, structured databases, and
real-time streams. All through MCP tools, zero configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:00:50 +01:00
Alejandro Gutiérrez
1aaa483d60 feat: v0.4.0 — File sharing + multi-target messages
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
Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.

Broker infra: MinIO in docker-compose, internal network.
HTTP POST /upload endpoint. WS handlers for get_file,
list_files, file_status, delete_file.

Multi-target: send_message(to:) accepts string or array.
Targets deduplicated before delivery.

Targeted views: MCP instructions teach Claude to send
tailored messages per audience instead of generic broadcasts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:56:01 +01:00
Alejandro Gutiérrez
99d9d19079 docs: update spec with files, multi-target, views, infra vision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:48:32 +01:00
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
167 changed files with 66153 additions and 1046 deletions

View File

@@ -16,3 +16,6 @@ URL="http://localhost:3000"
# Default locale of the apps, can be overridden separately in each app.
DEFAULT_LOCALE="en"
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
CLI_SYNC_SECRET="<your-cli-sync-secret>"

3
.gitignore vendored
View File

@@ -45,6 +45,9 @@ yarn-error.log*
# local env files
.env*.local
# secrets
.cli_sync_secret
# vercel
.vercel

30
CLAUDE.md Normal file
View File

@@ -0,0 +1,30 @@
# claudemesh
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
## Structure
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`
- `apps/cli/``claudemesh-cli` npm package (CLI + MCP server)
- `apps/web/` — Marketing site + dashboard at claudemesh.com
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
## Key docs
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
- `docs/protocol.md` — Wire protocol reference
- `docs/roadmap.md` — Public roadmap (shipped + planned)
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
## Deploy
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`
- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks`
- **Web:** Vercel auto-deploy on push to GitHub
## Dev
- Monorepo: pnpm workspaces + Turborepo
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
- CLI link for local testing: `cd apps/cli && npm link`

1024
SPEC.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
EXPOSE 7900
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
# Non-root user (oven/bun image ships with 'bun' uid 1000)

View File

@@ -15,10 +15,14 @@
},
"prettier": "@turbostarter/prettier-config",
"dependencies": {
"@qdrant/js-client-rest": "1.17.0",
"@turbostarter/db": "workspace:*",
"@turbostarter/shared": "workspace:*",
"drizzle-orm": "0.44.7",
"grammy": "^1.35.0",
"libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"neo4j-driver": "6.0.1",
"ws": "8.20.0",
"zod": "catalog:"
},

215
apps/broker/src/audit.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* Signed audit log with hash-chain integrity.
*
* Every significant mesh event is recorded as an append-only entry.
* Each entry's SHA-256 hash includes the previous entry's hash,
* forming a tamper-evident chain per mesh. If any row is modified
* or deleted, all subsequent hashes will fail verification.
*
* NEVER logs message content (ciphertext or plaintext) — only metadata.
*/
import { createHash } from "node:crypto";
import { asc, desc, eq, sql, and } from "drizzle-orm";
import { db } from "./db";
import { auditLog } from "@turbostarter/db/schema/mesh";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
// ---------------------------------------------------------------------------
const lastHash = new Map<string, string>();
// ---------------------------------------------------------------------------
// Core audit logging
// ---------------------------------------------------------------------------
function computeHash(
prevHash: string,
meshId: string,
eventType: string,
actorMemberId: string | null,
payload: Record<string, unknown>,
createdAt: Date,
): string {
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
return createHash("sha256").update(input).digest("hex");
}
/**
* Append an audit entry for a mesh event.
*
* Fire-and-forget safe — callers should `void audit(...)` or
* `.catch(log.warn)` to avoid blocking the hot path.
*/
export async function audit(
meshId: string,
eventType: string,
actorMemberId: string | null,
actorDisplayName: string | null,
payload: Record<string, unknown>,
): Promise<void> {
const prevHash = lastHash.get(meshId) ?? "genesis";
const createdAt = new Date();
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
try {
await db.insert(auditLog).values({
meshId,
eventType,
actorMemberId,
actorDisplayName,
payload,
prevHash,
hash,
createdAt,
});
lastHash.set(meshId, hash);
} catch (e) {
log.warn("audit log insert failed", {
mesh_id: meshId,
event_type: eventType,
error: e instanceof Error ? e.message : String(e),
});
}
}
// ---------------------------------------------------------------------------
// Startup: load last hash per mesh from DB
// ---------------------------------------------------------------------------
export async function loadLastHashes(): Promise<void> {
try {
// For each mesh, find the most recent audit entry by id (serial).
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
SELECT DISTINCT ON (mesh_id) mesh_id, hash
FROM mesh.audit_log
ORDER BY mesh_id, id DESC
`);
for (const row of rows) {
lastHash.set(row.mesh_id, row.hash);
}
log.info("audit: loaded last hashes", { meshes: lastHash.size });
} catch (e) {
// Table may not exist yet on first boot — that's fine.
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
error: e instanceof Error ? e.message : String(e),
});
}
}
// ---------------------------------------------------------------------------
// Chain verification
// ---------------------------------------------------------------------------
export async function verifyChain(
meshId: string,
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
const rows = await db
.select()
.from(auditLog)
.where(eq(auditLog.meshId, meshId))
.orderBy(asc(auditLog.id));
if (rows.length === 0) {
return { valid: true, entries: 0 };
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
// Verify prevHash linkage
if (row.prevHash !== expectedPrevHash) {
return { valid: false, entries: rows.length, brokenAt: row.id };
}
// Recompute hash and verify
const recomputed = computeHash(
row.prevHash,
row.meshId,
row.eventType,
row.actorMemberId,
row.payload as Record<string, unknown>,
row.createdAt,
);
if (recomputed !== row.hash) {
return { valid: false, entries: rows.length, brokenAt: row.id };
}
}
return { valid: true, entries: rows.length };
}
// ---------------------------------------------------------------------------
// Query: paginated audit entries
// ---------------------------------------------------------------------------
export async function queryAuditLog(
meshId: string,
options?: { limit?: number; offset?: number; eventType?: string },
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
const conditions = [eq(auditLog.meshId, meshId)];
if (options?.eventType) {
conditions.push(eq(auditLog.eventType, options.eventType));
}
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
const [rows, countResult] = await Promise.all([
db
.select()
.from(auditLog)
.where(where)
.orderBy(desc(auditLog.id))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(auditLog)
.where(where),
]);
return {
entries: rows.map((r) => ({
id: r.id,
eventType: r.eventType,
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
payload: r.payload as Record<string, unknown>,
hash: r.hash,
createdAt: r.createdAt.toISOString(),
})),
total: Number(countResult[0]?.count ?? 0),
};
}
// ---------------------------------------------------------------------------
// Ensure table exists (raw DDL for first-boot before migrations run)
// ---------------------------------------------------------------------------
export async function ensureAuditLogTable(): Promise<void> {
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS mesh.audit_log (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
event_type TEXT NOT NULL,
actor_member_id TEXT,
actor_display_name TEXT,
payload JSONB NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
} catch (e) {
log.warn("audit: ensureAuditLogTable failed", {
error: e instanceof Error ? e.message : String(e),
});
}
}

View File

@@ -0,0 +1,68 @@
/**
* Broker-side symmetric encryption for persisting resolved env vars.
*
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
* a random key is generated and logged on first use — operator should
* persist it to survive broker restarts.
*
* This is NOT the same as peer-side E2E crypto (libsodium). This is
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
*/
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { env } from "./env";
import { log } from "./logger";
const ALGO = "aes-256-gcm";
const IV_LEN = 12;
const TAG_LEN = 16;
let _key: Buffer | null = null;
function getKey(): Buffer {
if (_key) return _key;
if (env.BROKER_ENCRYPTION_KEY && env.BROKER_ENCRYPTION_KEY.length === 64) {
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
} else {
_key = randomBytes(32);
log.warn("BROKER_ENCRYPTION_KEY not set — generated ephemeral key. " +
"Set BROKER_ENCRYPTION_KEY=" + _key.toString("hex") + " to persist across restarts.");
}
return _key;
}
/**
* Encrypt a JSON-serializable value. Returns a base64 string containing
* IV + ciphertext + auth tag.
*/
export function encryptForStorage(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Pack: IV (12) + tag (16) + ciphertext
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
/**
* Decrypt a value produced by encryptForStorage. Returns the plaintext
* string, or null if decryption fails (wrong key, tampered).
*/
export function decryptFromStorage(packed: string): string | null {
try {
const key = getKey();
const buf = Buffer.from(packed, "base64");
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
} catch {
return null;
}
}

File diff suppressed because it is too large Load Diff

133
apps/broker/src/cli-sync.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* POST /cli-sync handler.
*
* Accepts a sync JWT from the dashboard, creates or finds member rows
* for each mesh in the token, and returns mesh details + member IDs.
*/
import { and, eq, isNull } from "drizzle-orm";
import { db } from "./db";
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
// Import schema tables
import {
mesh as meshTable,
meshMember as memberTable,
} from "@turbostarter/db/schema/mesh";
import { generateId } from "@turbostarter/shared/utils";
export interface CliSyncRequest {
sync_token: string;
peer_pubkey: string; // ed25519 hex (64 chars)
display_name: string;
}
export interface CliSyncResponse {
ok: true;
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
export interface CliSyncError {
ok: false;
error: string;
}
export async function handleCliSync(
body: CliSyncRequest,
): Promise<CliSyncResponse | CliSyncError> {
// 1. Validate inputs
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
}
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
}
// 2. Verify JWT
const tokenResult = await verifySyncToken(body.sync_token);
if (!tokenResult.ok) {
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
}
const payload = tokenResult.payload;
// 3. For each mesh in the token, create or find a member row
const resultMeshes: CliSyncResponse["meshes"] = [];
for (const tokenMesh of payload.meshes) {
// Verify mesh exists and is not archived
const [m] = await db
.select({ id: meshTable.id, slug: meshTable.slug })
.from(meshTable)
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
if (!m) {
// Skip meshes that don't exist (could have been deleted)
continue;
}
// Check if this pubkey is already a member of this mesh
const [existing] = await db
.select({ id: memberTable.id, role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.meshId, tokenMesh.id),
eq(memberTable.peerPubkey, body.peer_pubkey),
isNull(memberTable.revokedAt),
),
);
let memberId: string;
let role: "admin" | "member";
if (existing) {
// Already a member — update dashboard link + display name
memberId = existing.id;
role = existing.role;
await db
.update(memberTable)
.set({
dashboardUserId: payload.sub,
displayName: body.display_name,
})
.where(eq(memberTable.id, existing.id));
} else {
// Create new member row
memberId = generateId();
role = tokenMesh.role;
await db.insert(memberTable).values({
id: memberId,
meshId: tokenMesh.id,
peerPubkey: body.peer_pubkey,
displayName: body.display_name,
role: tokenMesh.role,
dashboardUserId: payload.sub,
});
}
resultMeshes.push({
mesh_id: tokenMesh.id,
slug: m.slug,
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
member_id: memberId,
role,
});
}
if (resultMeshes.length === 0) {
return { ok: false, error: "no valid meshes found in sync token" };
}
return {
ok: true,
account_id: payload.sub,
meshes: resultMeshes,
};
}

View File

@@ -20,6 +20,20 @@ const envSchema = z.object({
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"),
NEO4J_PASSWORD: z.string().default("changeme"),
RUNNER_URL: z.string().default("http://runner:7901"),
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),

File diff suppressed because it is too large Load Diff

146
apps/broker/src/jwt.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* JWT verification for CLI sync tokens.
*
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
* shared secret between dashboard and broker via env var.
*
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
*/
import { env } from "./env";
// --- Types ---
export interface SyncTokenPayload {
sub: string; // dashboard user ID
email: string;
meshes: Array<{
id: string;
slug: string;
role: "admin" | "member";
}>;
action: "sync" | "create";
newMesh?: {
name: string;
slug: string;
};
jti: string; // unique token ID for replay prevention
iat: number;
exp: number;
}
// --- JTI dedup ---
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
// Sweep expired JTIs every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [jti, exp] of usedJtis) {
if (exp < now) usedJtis.delete(jti);
}
}, 5 * 60_000);
// --- Verification ---
/**
* Verify and decode a sync token JWT.
* Returns the decoded payload on success, or an error string on failure.
*/
export async function verifySyncToken(
token: string,
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
// Get shared secret from env
const secret = env.CLI_SYNC_SECRET;
if (!secret) {
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
}
try {
// Decode JWT manually (HS256)
const parts = token.split(".");
if (parts.length !== 3) {
return { ok: false, error: "malformed JWT" };
}
const headerB64 = parts[0]!;
const payloadB64 = parts[1]!;
const signatureB64 = parts[2]!;
// Verify signature (HS256)
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
if (!valid) {
return { ok: false, error: "invalid signature" };
}
// Decode header — must be HS256
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
if (header.alg !== "HS256") {
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
}
// Decode payload
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64)),
) as SyncTokenPayload;
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
return { ok: false, error: "token expired" };
}
// Check iat not in the future (30s tolerance)
if (payload.iat && payload.iat > now + 30) {
return { ok: false, error: "token issued in the future" };
}
// JTI dedup
if (!payload.jti) {
return { ok: false, error: "missing jti" };
}
if (usedJtis.has(payload.jti)) {
return { ok: false, error: "token already used" };
}
// Mark as used with expiry time
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
// Basic validation
if (!payload.sub || !payload.email) {
return { ok: false, error: "missing sub or email" };
}
if (!Array.isArray(payload.meshes)) {
return { ok: false, error: "missing meshes array" };
}
return { ok: true, payload };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
// --- Helpers ---
function base64UrlDecode(input: string): Uint8Array {
// Add padding
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) base64 += "=";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@@ -0,0 +1,284 @@
/**
* Member profile REST API handlers.
*
* PATCH /mesh/:meshId/member/:memberId — update member profile
* GET /mesh/:meshId/members — list all members with online status
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
*
* These are standalone handler functions. Route wiring happens in index.ts.
*/
import { and, eq, isNull, sql } from "drizzle-orm";
import { db } from "./db";
import {
mesh as meshTable,
meshMember as memberTable,
presence as presenceTable,
} from "@turbostarter/db/schema/mesh";
// --- Types ---
export interface MemberProfileUpdate {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: "push" | "inbox" | "off";
}
export interface MemberPermissionUpdate {
permission?: "admin" | "member"; // only admins can change this
}
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
interface SelfEditablePolicy {
displayName: boolean;
roleTag: boolean;
groups: boolean;
messageMode: boolean;
}
// --- Handlers ---
/**
* Update a member's profile fields.
*
* Authorization:
* - If caller is the target member: check mesh.selfEditable for each field
* - If caller is a mesh admin: allow all fields
* - permission field: admin-only always
*
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
*/
export async function updateMemberProfile(
meshId: string,
memberId: string,
callerMemberId: string, // from auth header or WS connection
updates: MemberUpdateRequest,
): Promise<
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
| { ok: false; error: string }
> {
// 1. Load mesh for selfEditable policy
const [m] = await db
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
.from(meshTable)
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
if (!m) return { ok: false, error: "mesh not found" };
// 2. Load caller's member row to check permission
const [caller] = await db
.select({ id: memberTable.id, role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.id, callerMemberId),
eq(memberTable.meshId, meshId),
isNull(memberTable.revokedAt),
),
);
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
const isAdmin = caller.role === "admin";
const isSelf = callerMemberId === memberId;
if (!isAdmin && !isSelf) {
return {
ok: false,
error: "not authorized — only admins or self can edit",
};
}
// 3. Check self-edit permissions for non-admin self-edits
const policy: SelfEditablePolicy =
(m.selfEditable as SelfEditablePolicy) ?? {
displayName: true,
roleTag: true,
groups: true,
messageMode: true,
};
const rejected: string[] = [];
if (!isAdmin && isSelf) {
if (updates.displayName !== undefined && !policy.displayName)
rejected.push("displayName");
if (updates.roleTag !== undefined && !policy.roleTag)
rejected.push("roleTag");
if (updates.groups !== undefined && !policy.groups)
rejected.push("groups");
if (updates.messageMode !== undefined && !policy.messageMode)
rejected.push("messageMode");
if (updates.permission !== undefined) rejected.push("permission");
}
if (rejected.length > 0) {
return {
ok: false,
error: `admin-managed fields: ${rejected.join(", ")}`,
};
}
// 4. Build update set
const set: Record<string, unknown> = {};
const changes: MemberProfileUpdate = {};
if (updates.displayName !== undefined) {
set.displayName = updates.displayName;
changes.displayName = updates.displayName;
}
if (updates.roleTag !== undefined) {
set.roleTag = updates.roleTag;
changes.roleTag = updates.roleTag;
}
if (updates.groups !== undefined) {
set.defaultGroups = updates.groups;
changes.groups = updates.groups;
}
if (updates.messageMode !== undefined) {
set.messageMode = updates.messageMode;
changes.messageMode = updates.messageMode;
}
if (updates.permission !== undefined && isAdmin) {
set.role = updates.permission;
}
if (Object.keys(set).length === 0) {
return { ok: false, error: "no fields to update" };
}
// 5. Update member row
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
// 6. Read back the updated member
const [updated] = await db
.select()
.from(memberTable)
.where(eq(memberTable.id, memberId));
if (!updated) return { ok: false, error: "member not found after update" };
return {
ok: true,
member: {
id: updated.id,
displayName: updated.displayName,
roleTag: updated.roleTag,
groups: updated.defaultGroups,
messageMode: updated.messageMode,
permission: updated.role,
dashboardUserId: updated.dashboardUserId,
joinedAt: updated.joinedAt,
lastSeenAt: updated.lastSeenAt,
},
changes,
};
}
/**
* List all members of a mesh with online status.
*/
export async function listMeshMembers(
meshId: string,
): Promise<
| { ok: true; members: Array<Record<string, unknown>> }
| { ok: false; error: string }
> {
// Verify mesh exists
const [m] = await db
.select({ id: meshTable.id })
.from(meshTable)
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
if (!m) return { ok: false, error: "mesh not found" };
// Get all non-revoked members
const members = await db
.select()
.from(memberTable)
.where(
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
);
// Early return for empty member list (avoids invalid SQL IN clause)
if (members.length === 0) {
return { ok: true, members: [] };
}
// Get active presences for online status
const activePresences = await db
.select({
memberId: presenceTable.memberId,
count: sql<number>`count(*)::int`,
})
.from(presenceTable)
.where(
and(
isNull(presenceTable.disconnectedAt),
sql`${presenceTable.memberId} IN (${sql.join(
members.map((m) => sql`${m.id}`),
sql`, `,
)})`,
),
)
.groupBy(presenceTable.memberId);
const onlineMap = new Map(
activePresences.map((p) => [p.memberId, p.count]),
);
return {
ok: true,
members: members.map((member) => ({
id: member.id,
displayName: member.displayName,
roleTag: member.roleTag,
groups: member.defaultGroups,
messageMode: member.messageMode,
permission: member.role,
dashboardUserId: member.dashboardUserId,
joinedAt: member.joinedAt?.toISOString(),
lastSeenAt: member.lastSeenAt?.toISOString(),
online: onlineMap.has(member.id),
sessionCount: onlineMap.get(member.id) ?? 0,
})),
};
}
/**
* Update mesh settings (currently: selfEditable policy).
* Admin-only.
*/
export async function updateMeshSettings(
meshId: string,
callerMemberId: string,
settings: { selfEditable?: SelfEditablePolicy },
): Promise<{ ok: true } | { ok: false; error: string }> {
// Check caller is admin
const [caller] = await db
.select({ role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.id, callerMemberId),
eq(memberTable.meshId, meshId),
isNull(memberTable.revokedAt),
),
);
if (!caller || caller.role !== "admin") {
return { ok: false, error: "admin access required" };
}
const set: Record<string, unknown> = {};
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
if (Object.keys(set).length === 0) {
return { ok: false, error: "no settings to update" };
}
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
return { ok: true };
}

28
apps/broker/src/minio.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* MinIO client for file storage.
*
* Each mesh gets its own bucket (mesh-{meshId}). Files are stored under
* a key path that encodes persistence and origin:
* - persistent: shared/{fileId}/{originalName}
* - ephemeral: ephemeral/{YYYY-MM-DD}/{fileId}/{originalName}
*/
import { Client } from "minio";
import { env } from "./env";
export const minioClient = new Client({
endPoint: env.MINIO_ENDPOINT.split(":")[0]!,
port: parseInt(env.MINIO_ENDPOINT.split(":")[1] || "9000"),
useSSL: env.MINIO_USE_SSL,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
});
export async function ensureBucket(name: string): Promise<void> {
const exists = await minioClient.bucketExists(name);
if (!exists) await minioClient.makeBucket(name);
}
export function meshBucketName(meshId: string): string {
return `mesh-${meshId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
}

View File

@@ -0,0 +1,22 @@
import neo4j from "neo4j-driver";
import { env } from "./env";
export const neo4jDriver = neo4j.driver(
env.NEO4J_URL,
neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD),
);
export function meshDbName(meshId: string): string {
return `mesh_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
}
export async function ensureDatabase(name: string): Promise<void> {
const session = neo4jDriver.session({ database: "system" });
try {
await session.run(`CREATE DATABASE $name IF NOT EXISTS`, { name });
} catch {
/* may not support multi-db in community edition — fall back to default */
} finally {
await session.close();
}
}

24
apps/broker/src/qdrant.ts Normal file
View File

@@ -0,0 +1,24 @@
import { QdrantClient } from "@qdrant/js-client-rest";
import { env } from "./env";
export const qdrant = new QdrantClient({ url: env.QDRANT_URL });
export function meshCollectionName(
meshId: string,
collection: string,
): string {
return `mesh_${meshId}_${collection}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
}
export async function ensureCollection(
name: string,
vectorSize = 1536,
): Promise<void> {
try {
await qdrant.getCollection(name);
} catch {
await qdrant.createCollection(name, {
vectors: { size: vectorSize, distance: "Cosine" },
});
}
}

View File

@@ -0,0 +1,788 @@
/**
* Service Manager — lifecycle management for mesh-deployed MCP servers.
*
* Each deployed MCP server runs as a child process with its own stdio pipe.
* The manager spawns, monitors, restarts, and routes tool calls to them.
*
* In production: child processes run inside a Docker container (one per mesh).
* In dev: child processes run directly on the broker host.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { createInterface } from "node:readline";
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** MCP tool definition returned by tools/list. */
export interface ToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
/** Per-service deploy-time configuration. */
export interface ServiceConfig {
env?: Record<string, string>;
memory_mb?: number;
cpus?: number;
network_allow?: string[];
runtime?: "node" | "python" | "bun";
}
/** Observable lifecycle states. */
export type ServiceStatus =
| "building"
| "installing"
| "running"
| "stopped"
| "failed"
| "crashed"
| "restarting";
/** Internal bookkeeping for a spawned service. */
interface ManagedService {
name: string;
meshId: string;
process: ChildProcess | null;
tools: ToolDef[];
status: ServiceStatus;
config: ServiceConfig;
sourcePath: string;
runtime: "node" | "python" | "bun";
restartCount: number;
maxRestarts: number;
healthFailures: number;
logBuffer: string[]; // ring buffer, max LOG_BUFFER_SIZE
pendingCalls: Map<
string,
{
resolve: (result: { result?: unknown; error?: string }) => void;
timer: NodeJS.Timeout;
}
>;
pid?: number;
startedAt?: Date;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LOG_BUFFER_SIZE = 1000;
const HEALTH_INTERVAL_MS = 30_000;
const HEALTH_TIMEOUT_MS = 5_000;
const MAX_HEALTH_FAILURES = 3;
const DEFAULT_MAX_RESTARTS = 5;
const CALL_TIMEOUT_MS = 25_000;
const SERVICES_BASE_DIR =
process.env.CLAUDEMESH_SERVICES_DIR ?? "/var/claudemesh/services";
// ---------------------------------------------------------------------------
// Service registry
// ---------------------------------------------------------------------------
const services = new Map<string, ManagedService>(); // keyed by "meshId:serviceName"
let healthTimer: NodeJS.Timer | null = null;
function serviceKey(meshId: string, name: string): string {
return `${meshId}:${name}`;
}
/** Validate service name: alphanumeric, hyphens, underscores only. No path traversal. */
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
export function validateServiceName(name: string): string | null {
if (!SAFE_NAME_RE.test(name)) {
return "service name must be 1-64 chars, alphanumeric/hyphens/underscores, starting with alphanumeric";
}
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
return "service name must not contain path separators";
}
return null; // valid
}
// ---------------------------------------------------------------------------
// Runtime detection
// ---------------------------------------------------------------------------
/**
* Detect the runtime for a service based on its source directory contents.
*
* Priority: bun (lockfile/config) > node (package.json) > python
* (pyproject.toml / requirements.txt). Falls back to node.
*/
export function detectRuntime(sourcePath: string): "node" | "python" | "bun" {
if (
existsSync(join(sourcePath, "bun.lockb")) ||
existsSync(join(sourcePath, "bunfig.toml"))
) {
return "bun";
}
if (existsSync(join(sourcePath, "package.json"))) {
return "node";
}
if (
existsSync(join(sourcePath, "pyproject.toml")) ||
existsSync(join(sourcePath, "requirements.txt"))
) {
return "python";
}
return "node"; // default
}
// ---------------------------------------------------------------------------
// Entry point detection
// ---------------------------------------------------------------------------
function detectEntry(
sourcePath: string,
runtime: "node" | "python" | "bun",
): { command: string; args: string[] } {
if (runtime === "python") {
if (existsSync(join(sourcePath, "requirements.txt"))) {
for (const entry of [
"server.py",
"src/server.py",
"main.py",
"src/main.py",
]) {
if (existsSync(join(sourcePath, entry))) {
return { command: "python", args: [entry] };
}
}
}
if (existsSync(join(sourcePath, "pyproject.toml"))) {
return { command: "python", args: ["-m", "server"] };
}
return { command: "python", args: ["server.py"] };
}
// Node / Bun
const cmd = runtime === "bun" ? "bun" : "node";
if (existsSync(join(sourcePath, "package.json"))) {
try {
const pkg = JSON.parse(
readFileSync(join(sourcePath, "package.json"), "utf-8"),
);
if (pkg.main) return { command: cmd, args: [pkg.main] };
if (pkg.bin) {
const bin =
typeof pkg.bin === "string"
? pkg.bin
: (Object.values(pkg.bin)[0] as string);
if (bin) return { command: cmd, args: [bin] };
}
} catch {
/* ignore parse errors */
}
}
// Common entry points
for (const entry of [
"dist/index.js",
"src/index.js",
"src/index.ts",
"index.js",
]) {
if (existsSync(join(sourcePath, entry))) {
return { command: cmd, args: [entry] };
}
}
return { command: cmd, args: ["src/index.js"] };
}
// ---------------------------------------------------------------------------
// Install dependencies
// ---------------------------------------------------------------------------
/**
* Install dependencies for a service. Resolves on success, rejects with
* the tail of stderr on failure.
*/
export async function installDeps(
sourcePath: string,
runtime: "node" | "python" | "bun",
): Promise<void> {
return new Promise((resolve, reject) => {
let cmd: string;
let args: string[];
if (runtime === "python") {
if (existsSync(join(sourcePath, "requirements.txt"))) {
cmd = "pip";
args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
} else {
cmd = "pip";
args = ["install", "--no-cache-dir", "."];
}
} else if (runtime === "bun") {
cmd = "bun";
args = ["install"];
} else {
cmd = "npm";
args = ["install", "--production", "--legacy-peer-deps"];
}
const child = spawn(cmd, args, {
cwd: sourcePath,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (d: Buffer) => {
stderr += d.toString();
});
child.on("exit", (code) => {
if (code === 0) resolve();
else
reject(
new Error(
`${cmd} install failed (exit ${code}): ${stderr.slice(-500)}`,
),
);
});
child.on("error", reject);
});
}
// ---------------------------------------------------------------------------
// Log ring buffer
// ---------------------------------------------------------------------------
function appendLog(svc: ManagedService, line: string): void {
svc.logBuffer.push(`${new Date().toISOString()} ${line}`);
if (svc.logBuffer.length > LOG_BUFFER_SIZE) {
svc.logBuffer.shift();
}
}
// ---------------------------------------------------------------------------
// MCP JSON-RPC helpers
// ---------------------------------------------------------------------------
let callIdCounter = 0;
function sendMcpRequest(
svc: ManagedService,
method: string,
params?: unknown,
): Promise<{ result?: unknown; error?: string }> {
return new Promise((resolve) => {
if (!svc.process || !svc.process.stdin?.writable) {
resolve({ error: "service not running" });
return;
}
const id = `call_${++callIdCounter}`;
const request = {
jsonrpc: "2.0",
id,
method,
...(params ? { params } : {}),
};
const timer = setTimeout(() => {
svc.pendingCalls.delete(id);
resolve({ error: `tool call timed out after ${CALL_TIMEOUT_MS}ms` });
}, CALL_TIMEOUT_MS);
svc.pendingCalls.set(id, { resolve, timer });
try {
svc.process.stdin!.write(JSON.stringify(request) + "\n");
} catch (e) {
clearTimeout(timer);
svc.pendingCalls.delete(id);
resolve({
error: `write failed: ${e instanceof Error ? e.message : String(e)}`,
});
}
});
}
// ---------------------------------------------------------------------------
// Initialize MCP server (handshake + tool discovery)
// ---------------------------------------------------------------------------
async function initializeMcp(svc: ManagedService): Promise<ToolDef[]> {
// MCP initialize handshake
const initResult = await sendMcpRequest(svc, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
});
if (initResult.error) {
throw new Error(`MCP initialize failed: ${initResult.error}`);
}
// Send initialized notification (no response expected)
if (svc.process?.stdin?.writable) {
svc.process.stdin.write(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}) + "\n",
);
}
// Fetch tool list
const toolsResult = await sendMcpRequest(svc, "tools/list", {});
if (toolsResult.error) {
throw new Error(`tools/list failed: ${toolsResult.error}`);
}
const result = toolsResult.result as { tools?: ToolDef[] } | undefined;
return result?.tools ?? [];
}
// ---------------------------------------------------------------------------
// Spawn an MCP server child process
// ---------------------------------------------------------------------------
function spawnService(svc: ManagedService): void {
const { command, args } = detectEntry(svc.sourcePath, svc.runtime);
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...(svc.config.env ?? {}),
NODE_ENV: "production",
};
const child = spawn(command, args, {
cwd: svc.sourcePath,
stdio: ["pipe", "pipe", "pipe"],
env,
});
svc.process = child;
svc.pid = child.pid;
svc.startedAt = new Date();
svc.status = "running";
svc.healthFailures = 0;
// Read MCP JSON-RPC responses from stdout
const rl = createInterface({ input: child.stdout! });
rl.on("line", (line) => {
try {
const msg = JSON.parse(line);
if (msg.id && svc.pendingCalls.has(String(msg.id))) {
const pending = svc.pendingCalls.get(String(msg.id))!;
clearTimeout(pending.timer);
svc.pendingCalls.delete(String(msg.id));
if (msg.error) {
pending.resolve({
error: msg.error.message ?? JSON.stringify(msg.error),
});
} else {
pending.resolve({ result: msg.result });
}
}
} catch {
// Not JSON — treat as log output
appendLog(svc, `[stdout] ${line}`);
}
});
// Capture stderr as logs
const stderrRl = createInterface({ input: child.stderr! });
stderrRl.on("line", (line) => {
appendLog(svc, `[stderr] ${line}`);
});
child.on("exit", (code, signal) => {
log.warn("service exited", {
service: svc.name,
mesh_id: svc.meshId,
code,
signal,
restarts: svc.restartCount,
});
// Reject all pending calls
for (const [, pending] of svc.pendingCalls) {
clearTimeout(pending.timer);
pending.resolve({ error: "service crashed" });
}
svc.pendingCalls.clear();
svc.process = null;
svc.pid = undefined;
// Auto-restart if under limit
if (svc.status === "running" && svc.restartCount < svc.maxRestarts) {
svc.restartCount++;
svc.status = "restarting";
log.info("auto-restarting service", {
service: svc.name,
attempt: svc.restartCount,
});
setTimeout(() => spawnService(svc), 1000 * svc.restartCount); // backoff
} else if (svc.status === "running") {
svc.status = "crashed";
log.error("service max restarts exceeded", {
service: svc.name,
restarts: svc.restartCount,
});
}
});
child.on("error", (err) => {
log.error("service spawn error", {
service: svc.name,
error: err.message,
});
svc.status = "failed";
});
log.info("service spawned", {
service: svc.name,
mesh_id: svc.meshId,
pid: child.pid,
command,
args,
runtime: svc.runtime,
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Deploy (or redeploy) an MCP server.
*
* Installs dependencies, spawns the child process, runs the MCP
* initialize handshake, and returns the discovered tool list.
*/
export async function deploy(opts: {
meshId: string;
name: string;
sourcePath: string;
config: ServiceConfig;
resolvedEnv?: Record<string, string>;
}): Promise<{ tools: ToolDef[]; status: ServiceStatus }> {
const key = serviceKey(opts.meshId, opts.name);
// Kill existing if redeploying
const existing = services.get(key);
if (existing?.process) {
existing.process.kill("SIGTERM");
await new Promise((r) => setTimeout(r, 1000));
}
const runtime = opts.config.runtime ?? detectRuntime(opts.sourcePath);
const svc: ManagedService = {
name: opts.name,
meshId: opts.meshId,
process: null,
tools: [],
status: "installing",
config: {
...opts.config,
env: { ...(opts.config.env ?? {}), ...(opts.resolvedEnv ?? {}) },
},
sourcePath: opts.sourcePath,
runtime,
restartCount: 0,
maxRestarts: DEFAULT_MAX_RESTARTS,
healthFailures: 0,
logBuffer: [],
pendingCalls: new Map(),
};
services.set(key, svc);
// Install dependencies
try {
await installDeps(opts.sourcePath, runtime);
} catch (e) {
svc.status = "failed";
appendLog(
svc,
`Install failed: ${e instanceof Error ? e.message : String(e)}`,
);
throw e;
}
// Spawn and initialize
spawnService(svc);
// Wait a moment for the process to start
await new Promise((r) => setTimeout(r, 500));
// Get tool list via MCP initialize handshake
try {
svc.tools = await initializeMcp(svc);
log.info("service deployed", {
service: opts.name,
mesh_id: opts.meshId,
tools: svc.tools.length,
runtime,
});
} catch (e) {
svc.status = "failed";
appendLog(
svc,
`MCP init failed: ${e instanceof Error ? e.message : String(e)}`,
);
throw e;
}
return { tools: svc.tools, status: svc.status };
}
/**
* Undeploy a running service. Sends SIGTERM, waits for graceful exit
* (up to 10 s), then SIGKILL. All pending tool calls are rejected.
*/
export async function undeploy(meshId: string, name: string): Promise<void> {
const key = serviceKey(meshId, name);
const svc = services.get(key);
if (!svc) return;
svc.status = "stopped";
if (svc.process) {
svc.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
svc.process?.kill("SIGKILL");
resolve();
}, 10_000);
svc.process?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
// Reject pending calls
for (const [, pending] of svc.pendingCalls) {
clearTimeout(pending.timer);
pending.resolve({ error: "service undeployed" });
}
services.delete(key);
log.info("service undeployed", { service: name, mesh_id: meshId });
}
/**
* Route a tool call to the named service. Returns the MCP response
* payload or an error string.
*/
export async function callTool(
meshId: string,
serverName: string,
toolName: string,
args: Record<string, unknown>,
): Promise<{ result?: unknown; error?: string }> {
const key = serviceKey(meshId, serverName);
const svc = services.get(key);
if (!svc) return { error: `service "${serverName}" not found` };
if (svc.status !== "running")
return { error: `service "${serverName}" is ${svc.status}` };
if (!svc.process)
return { error: `service "${serverName}" has no running process` };
return sendMcpRequest(svc, "tools/call", { name: toolName, arguments: args });
}
/**
* Return the last N log lines for a service (from its ring buffer).
*/
export function getLogs(meshId: string, name: string, lines = 50): string[] {
const key = serviceKey(meshId, name);
const svc = services.get(key);
if (!svc) return [];
return svc.logBuffer.slice(-Math.min(lines, LOG_BUFFER_SIZE));
}
/**
* Return current status, PID, restart count, tool list, and uptime
* for a single service. Returns null if the service doesn't exist.
*/
export function getStatus(
meshId: string,
name: string,
): {
status: ServiceStatus;
pid?: number;
restartCount: number;
tools: ToolDef[];
startedAt?: string;
} | null {
const key = serviceKey(meshId, name);
const svc = services.get(key);
if (!svc) return null;
return {
status: svc.status,
pid: svc.pid,
restartCount: svc.restartCount,
tools: svc.tools,
startedAt: svc.startedAt?.toISOString(),
};
}
/**
* Return the tool definitions for a service, or an empty array if the
* service doesn't exist.
*/
export function getTools(meshId: string, name: string): ToolDef[] {
const key = serviceKey(meshId, name);
const svc = services.get(key);
return svc?.tools ?? [];
}
/**
* List all services belonging to a mesh with summary info.
*/
export function listServices(
meshId: string,
): Array<{
name: string;
status: ServiceStatus;
toolCount: number;
runtime: string;
restartCount: number;
pid?: number;
}> {
const result: Array<{
name: string;
status: ServiceStatus;
toolCount: number;
runtime: string;
restartCount: number;
pid?: number;
}> = [];
for (const [key, svc] of services) {
if (!key.startsWith(`${meshId}:`)) continue;
result.push({
name: svc.name,
status: svc.status,
toolCount: svc.tools.length,
runtime: svc.runtime,
restartCount: svc.restartCount,
pid: svc.pid,
});
}
return result;
}
// ---------------------------------------------------------------------------
// Health check loop
// ---------------------------------------------------------------------------
async function healthCheckAll(): Promise<void> {
for (const [, svc] of services) {
if (svc.status !== "running" || !svc.process) continue;
const result = await sendMcpRequest(svc, "ping", {});
if (result.error) {
svc.healthFailures++;
log.warn("health check failed", {
service: svc.name,
failures: svc.healthFailures,
error: result.error,
});
if (svc.healthFailures >= MAX_HEALTH_FAILURES) {
log.error("health check threshold exceeded, restarting", {
service: svc.name,
});
svc.process.kill("SIGTERM");
// exit handler will trigger auto-restart
}
} else {
svc.healthFailures = 0;
}
}
}
/** Start the periodic health check loop (30 s interval). No-op if already running. */
export function startHealthChecks(): void {
if (healthTimer) return;
healthTimer = setInterval(healthCheckAll, HEALTH_INTERVAL_MS);
}
/** Stop the periodic health check loop. */
export function stopHealthChecks(): void {
if (healthTimer) {
clearInterval(healthTimer);
healthTimer = null;
}
}
// ---------------------------------------------------------------------------
// Restore all services on broker boot
// ---------------------------------------------------------------------------
/**
* Re-deploy every persisted service record. Called once at broker startup
* to bring services back after a restart. Failures are logged but don't
* prevent other services from restoring.
*/
export async function restoreAll(
getServiceRecords: () => Promise<
Array<{
meshId: string;
name: string;
sourcePath: string;
config: ServiceConfig;
resolvedEnv?: Record<string, string>;
}>
>,
): Promise<void> {
const records = await getServiceRecords();
log.info("restoring services", { count: records.length });
for (const record of records) {
try {
await deploy({
meshId: record.meshId,
name: record.name,
sourcePath: record.sourcePath,
config: record.config,
resolvedEnv: record.resolvedEnv,
});
log.info("service restored", {
service: record.name,
mesh_id: record.meshId,
});
} catch (e) {
log.error("service restore failed", {
service: record.name,
mesh_id: record.meshId,
error: e instanceof Error ? e.message : String(e),
});
}
}
startHealthChecks();
}
// ---------------------------------------------------------------------------
// Shutdown
// ---------------------------------------------------------------------------
/**
* Gracefully shut down all running services. Stops health checks, sends
* SIGTERM to every child, waits for exit, then clears the registry.
*/
export async function shutdownAll(): Promise<void> {
stopHealthChecks();
const promises: Promise<void>[] = [];
for (const [, svc] of services) {
if (svc.process) {
svc.status = "stopped";
promises.push(undeploy(svc.meshId, svc.name));
}
}
await Promise.allSettled(promises);
services.clear();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
/**
* JWT utilities for Telegram bridge connections.
*
* When a user connects their Telegram chat to a mesh, the broker generates
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
* this token to establish the connection.
*
* Pure-crypto implementation — no external JWT library.
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
*
* IMPORTANT: The JWT payload contains the member's secretKey.
* Never log the token or its decoded payload.
*/
import { createHmac } from "node:crypto";
// --- Types ---
export interface TelegramConnectPayload {
meshId: string;
meshSlug: string;
memberId: string;
pubkey: string;
secretKey: string; // ed25519 secret key — sensitive
createdBy: string; // Dashboard userId or CLI memberId
}
interface JwtClaims extends TelegramConnectPayload {
iss: string;
sub: string;
iat: number;
exp: number;
}
// --- Helpers ---
function base64url(data: string): string {
return Buffer.from(data).toString("base64url");
}
function base64urlDecode(str: string): string {
return Buffer.from(str, "base64url").toString("utf-8");
}
function sign(input: string, secret: string): string {
return createHmac("sha256", secret).update(input).digest("base64url");
}
// --- Public API ---
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
/**
* Create a signed JWT containing Telegram connect credentials.
* Expires in 15 minutes.
*/
export function generateTelegramConnectToken(
payload: TelegramConnectPayload,
secret: string,
): string {
const now = Math.floor(Date.now() / 1000);
const claims: JwtClaims = {
...payload,
iss: "claudemesh-broker",
sub: "telegram-connect",
iat: now,
exp: now + TOKEN_TTL_SECONDS,
};
const encodedPayload = base64url(JSON.stringify(claims));
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
const signature = sign(signingInput, secret);
return `${signingInput}.${signature}`;
}
/**
* Validate and decode a Telegram connect JWT.
* Returns the payload on success, or null on any failure
* (bad signature, expired, wrong subject).
*/
export function validateTelegramConnectToken(
token: string,
secret: string,
): TelegramConnectPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
// Verify signature
const signingInput = `${headerB64}.${payloadB64}`;
const expectedSignature = sign(signingInput, secret);
// Constant-time comparison to prevent timing attacks
const a = Buffer.from(signatureB64);
const b = Buffer.from(expectedSignature);
if (a.length !== b.length) return null;
const { timingSafeEqual } = require("node:crypto");
if (!timingSafeEqual(a, b)) return null;
// Verify header algorithm
const header = JSON.parse(base64urlDecode(headerB64));
if (header.alg !== "HS256") return null;
// Decode and validate claims
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
// Check subject
if (claims.sub !== "telegram-connect") return null;
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (claims.exp < now) return null;
// Check iat not in the future (30s tolerance)
if (claims.iat > now + 30) return null;
// Extract payload fields (strip JWT claims)
const {
meshId,
meshSlug,
memberId,
pubkey,
secretKey,
createdBy,
} = claims;
// Basic presence check
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
return null;
}
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
} catch {
return null;
}
}
/**
* Generate a Telegram deep link that passes the JWT as start parameter.
* Format: https://t.me/{botUsername}?start={token}
*/
export function generateDeepLink(token: string, botUsername: string): string {
return `https://t.me/${botUsername}?start=${token}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
/**
* Inbound webhook handler.
*
* External services POST JSON to `/hook/:meshId/:secret`. The broker
* verifies the secret against the mesh.webhook table, then pushes the
* payload to all connected peers in that mesh as a "webhook" push.
*/
import { eq, and } from "drizzle-orm";
import { db } from "./db";
import { meshWebhook } from "@turbostarter/db/schema/mesh";
import type { WSPushMessage } from "./types";
import { log } from "./logger";
export interface WebhookResult {
status: number;
body: { ok: boolean; delivered?: number; error?: string };
}
/**
* Look up a webhook by meshId + secret, verify it's active, then return
* the webhook name for push routing. Returns null if not found/inactive.
*/
async function findActiveWebhook(
meshId: string,
secret: string,
): Promise<{ id: string; name: string; meshId: string } | null> {
const rows = await db
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
.from(meshWebhook)
.where(
and(
eq(meshWebhook.meshId, meshId),
eq(meshWebhook.secret, secret),
eq(meshWebhook.active, true),
),
)
.limit(1);
return rows[0] ?? null;
}
/**
* Handle an inbound webhook HTTP request.
*
* @param meshId - mesh ID from the URL path
* @param secret - webhook secret from the URL path
* @param body - parsed JSON body from the request
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
* Returns the number of peers the message was delivered to.
*/
export async function handleWebhook(
meshId: string,
secret: string,
body: unknown,
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
): Promise<WebhookResult> {
try {
const webhook = await findActiveWebhook(meshId, secret);
if (!webhook) {
log.warn("webhook auth failed", { mesh_id: meshId });
return { status: 401, body: { ok: false, error: "unauthorized" } };
}
if (body === null || body === undefined || typeof body !== "object") {
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
}
const pushMsg: WSPushMessage = {
type: "push",
subtype: "webhook" as any,
event: webhook.name,
eventData: body as Record<string, unknown>,
messageId: crypto.randomUUID(),
meshId: webhook.meshId,
senderPubkey: `webhook:${webhook.name}`,
priority: "next",
nonce: "",
ciphertext: "",
createdAt: new Date().toISOString(),
};
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
log.info("webhook delivered", {
webhook_name: webhook.name,
mesh_id: webhook.meshId,
delivered,
});
return { status: 200, body: { ok: true, delivered } };
} catch (e) {
log.error("webhook handler error", {
error: e instanceof Error ? e.message : String(e),
});
return { status: 500, body: { ok: false, error: "internal error" } };
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "0.1.7",
"version": "0.9.2",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [
"claude-code",
@@ -47,6 +47,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"ws": "8.20.0",
"zod": "4.1.13"

View File

@@ -0,0 +1,90 @@
/**
* Localhost HTTP callback listener for CLI-to-browser sync flow.
*
* Endpoints:
* GET /ping → reachability check (web page preflight)
* GET /callback → receives sync token via ?token= query param
* OPTIONS * → CORS preflight for claudemesh.com
*/
import { createServer, type Server } from "node:http";
export interface CallbackListener {
/** Port the server is listening on. */
port: number;
/** Resolves when the /callback endpoint receives a token. */
token: Promise<string>;
/** Shut down the server. */
close: () => void;
}
/**
* Start a localhost HTTP server on a random OS-assigned port.
* Returns the port and a promise that resolves with the sync token.
*/
export function startCallbackListener(): Promise<CallbackListener> {
return new Promise((resolveStart) => {
let resolveToken: (token: string) => void;
const tokenPromise = new Promise<string>((r) => {
resolveToken = r;
});
const server: Server = createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
// CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "https://claudemesh.com",
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
// Reachability check — web page calls this before redirecting
if (url.pathname === "/ping") {
res.writeHead(200, {
"Content-Type": "text/plain",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end("ok");
return;
}
// Sync token callback
if (url.pathname === "/callback") {
const token = url.searchParams.get("token");
if (token) {
res.writeHead(200, {
"Content-Type": "text/html",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end(
"<html><body><h2>Done! You can close this tab.</h2><p>Launching claudemesh...</p></body></html>",
);
resolveToken(token);
// Close server after a short delay to ensure response is sent
setTimeout(() => server.close(), 500);
} else {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Missing token");
}
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as { port: number };
resolveStart({
port: addr.port,
token: tokenPromise,
close: () => server.close(),
});
});
});
}

View File

@@ -0,0 +1,4 @@
export { startCallbackListener, type CallbackListener } from "./callback-listener";
export { openBrowser } from "./open-browser";
export { generatePairingCode } from "./pairing-code";
export { syncWithBroker, type SyncResult } from "./sync-with-broker";

View File

@@ -0,0 +1,33 @@
/**
* Cross-platform browser opener.
* Respects BROWSER env var. Falls back to platform-specific launcher.
*/
import { exec } from "node:child_process";
/**
* Open a URL in the user's default browser.
* Returns true if the command succeeded, false otherwise.
* Non-fatal — callers should show the URL as fallback.
*/
export function openBrowser(url: string): Promise<boolean> {
// Validate URL
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return Promise.resolve(false);
}
const quoted = JSON.stringify(url);
const browserCmd = process.env.BROWSER;
const cmd = browserCmd
? `${browserCmd} ${quoted}`
: process.platform === "darwin"
? `open ${quoted}`
: process.platform === "win32"
? `rundll32 url.dll,FileProtocolHandler ${quoted}`
: `xdg-open ${quoted}`;
return new Promise((resolve) => {
exec(cmd, (err) => resolve(!err));
});
}

View File

@@ -0,0 +1,17 @@
/**
* Generate a short pairing code for CLI-to-browser visual confirmation.
* Excludes ambiguous characters (0/O, 1/l/I) for readability.
*/
import { randomBytes } from "node:crypto";
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
/**
* Generate a 4-character alphanumeric pairing code.
* Example output: "A3Kx", "Hn7v", "pQ4m"
*/
export function generatePairingCode(): string {
const bytes = randomBytes(4);
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
}

View File

@@ -0,0 +1,83 @@
/**
* Call the broker's POST /cli-sync endpoint to sync dashboard meshes.
*
* Takes a sync JWT (from the browser callback) and a freshly generated
* ed25519 keypair. The broker creates member rows and returns mesh details.
*/
export interface SyncResult {
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
/**
* Sync meshes from dashboard via broker.
*
* @param syncToken - JWT from the browser sync flow
* @param peerPubkey - ed25519 public key hex (64 chars)
* @param displayName - display name for the new member
* @param brokerBaseUrl - HTTPS base URL of the broker (derived from WSS URL)
*/
export async function syncWithBroker(
syncToken: string,
peerPubkey: string,
displayName: string,
brokerBaseUrl?: string,
): Promise<SyncResult> {
// Default broker URL — derive HTTPS from WSS
const base = brokerBaseUrl ?? deriveHttpUrl(
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
);
const res = await fetch(`${base}/cli-sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sync_token: syncToken,
peer_pubkey: peerPubkey,
display_name: displayName,
}),
});
if (!res.ok) {
const body = await res.text();
let msg: string;
try {
msg = JSON.parse(body).error ?? body;
} catch {
msg = body;
}
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
}
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
if (!body.ok) {
throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
}
return {
account_id: body.account_id!,
meshes: body.meshes!,
};
}
/**
* Convert a WSS broker URL to an HTTPS base URL.
* wss://ic.claudemesh.com/ws → https://ic.claudemesh.com
* ws://localhost:3001/ws → http://localhost:3001
*/
function deriveHttpUrl(wssUrl: string): string {
const url = new URL(wssUrl);
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
// Remove /ws path suffix
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
// Remove trailing slash
return url.toString().replace(/\/$/, "");
}

View File

@@ -0,0 +1,65 @@
import { loadConfig } from "../state/config";
export async function connectTelegram(args: string[]): Promise<void> {
const config = loadConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run 'claudemesh join' first.");
process.exit(1);
}
const mesh = config.meshes[0]!;
const linkOnly = args.includes("--link");
// Convert WS broker URL to HTTP
const brokerHttp = mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
console.log("Requesting Telegram connect token...");
const res = await fetch(`${brokerHttp}/tg/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meshId: mesh.meshId,
memberId: mesh.memberId,
pubkey: mesh.pubkey,
secretKey: mesh.secretKey,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
process.exit(1);
}
const { token, deepLink } = (await res.json()) as {
token: string;
deepLink: string;
};
if (linkOnly) {
console.log(deepLink);
return;
}
// Print QR code using simple block characters
console.log("\n Connect Telegram to your mesh:\n");
console.log(` ${deepLink}\n`);
console.log(" Open this link on your phone, or scan the QR code");
console.log(" with your Telegram camera.\n");
// Try to generate QR with qrcode-terminal if available
try {
const QRCode = require("qrcode-terminal");
QRCode.generate(deepLink, { small: true }, (code: string) => {
console.log(code);
});
} catch {
// qrcode-terminal not available, link is enough
console.log(" (Install qrcode-terminal for QR code display)");
}
}

View File

@@ -0,0 +1,59 @@
/**
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
*
* Opens a connection to one mesh, runs a callback, then closes cleanly.
* The caller never deals with connect/close lifecycle.
*/
import { hostname } from "node:os";
import { BrokerClient } from "../ws/client";
import { loadConfig } from "../state/config";
import type { JoinedMesh } from "../state/config";
export interface ConnectOpts {
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
meshSlug?: string | null;
/** Display name for this session. Defaults to hostname-pid. */
displayName?: string;
}
export async function withMesh<T>(
opts: ConnectOpts,
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
): Promise<T> {
const config = loadConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
let mesh: JoinedMesh;
if (opts.meshSlug) {
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
if (!found) {
console.error(
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
console.error(
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
const client = new BrokerClient(mesh, { displayName });
try {
await client.connect();
const result = await fn(client, mesh);
return result;
} finally {
client.close();
}
}

View File

@@ -0,0 +1,39 @@
/**
* `claudemesh create` — Create a new mesh with an optional template.
* Lists available templates if --list-templates is passed.
*/
import { listTemplates, getTemplate } from "../templates/index.js";
export function runCreate(args: Record<string, unknown>): void {
if (args["list-templates"]) {
console.log("Available mesh templates:\n");
for (const t of listTemplates()) {
console.log(` ${t.name}`);
console.log(` ${t.description}`);
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
console.log();
}
return;
}
const templateName = args.template as string | undefined;
if (templateName) {
const template = getTemplate(templateName);
if (!template) {
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
process.exit(1);
}
console.log(`Template "${template.name}" loaded:`);
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
console.log();
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
// Future: wire into actual mesh creation API
return;
}
console.log("Usage: claudemesh create --template <name>");
console.log(" claudemesh create --list-templates");
}

View File

@@ -0,0 +1,3 @@
export async function disconnectTelegram(): Promise<void> {
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
}

View File

@@ -0,0 +1,60 @@
/**
* `claudemesh inbox` — read pending peer messages.
*
* Connects, waits briefly for push delivery, drains the buffer, prints.
* Works best when message-mode is "inbox" or "off" (messages held at broker).
*/
import { withMesh } from "./connect";
import type { InboundPush } from "../ws/client";
export interface InboxFlags {
mesh?: string;
json?: boolean;
wait?: number;
}
function formatMessage(msg: InboundPush, useColor: boolean): string {
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
const from = msg.senderPubkey.slice(0, 8);
const time = new Date(msg.createdAt).toLocaleTimeString();
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
}
export async function runInbox(flags: InboxFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const waitMs = (flags.wait ?? 1) * 1000;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
// Wait briefly for broker to push any held messages.
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
const messages = client.drainPushBuffer();
if (flags.json) {
console.log(JSON.stringify(messages, null, 2));
return;
}
if (messages.length === 0) {
console.log(dim(`No messages on mesh "${mesh.slug}".`));
return;
}
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
console.log("");
for (const msg of messages) {
console.log(formatMessage(msg, useColor));
console.log("");
}
});
}

View File

@@ -0,0 +1,58 @@
/**
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
*
* Useful for AI agents to orient themselves in a mesh via bash.
*/
import { withMesh } from "./connect";
import { loadConfig } from "../state/config";
export interface InfoFlags {
mesh?: string;
json?: boolean;
}
export async function runInfo(flags: InfoFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const config = loadConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const [brokerInfo, peers, state] = await Promise.all([
client.meshInfo(),
client.listPeers(),
client.listState(),
]);
const output = {
slug: mesh.slug,
meshId: mesh.meshId,
memberId: mesh.memberId,
brokerUrl: mesh.brokerUrl,
displayName: config.displayName ?? null,
peerCount: peers.length,
stateCount: state.length,
...(brokerInfo ?? {}),
};
if (flags.json) {
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
console.log(dim(` mesh: ${mesh.meshId}`));
console.log(dim(` member: ${mesh.memberId}`));
console.log(` peers: ${peers.length} connected`);
console.log(` state: ${state.length} keys`);
if (brokerInfo && typeof brokerInfo === "object") {
for (const [k, v] of Object.entries(brokerInfo)) {
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
}
}
});
}

View File

@@ -29,6 +29,7 @@ import { homedir, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { loadConfig } from "../state/config";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
@@ -212,6 +213,92 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
);
}
/**
* All claudemesh MCP tool names, prefixed for allowedTools.
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
*/
const CLAUDEMESH_TOOLS = [
"mcp__claudemesh__cancel_scheduled",
"mcp__claudemesh__check_messages",
"mcp__claudemesh__claim_task",
"mcp__claudemesh__complete_task",
"mcp__claudemesh__create_stream",
"mcp__claudemesh__create_task",
"mcp__claudemesh__delete_file",
"mcp__claudemesh__file_status",
"mcp__claudemesh__forget",
"mcp__claudemesh__get_context",
"mcp__claudemesh__get_file",
"mcp__claudemesh__get_state",
"mcp__claudemesh__grant_file_access",
"mcp__claudemesh__graph_execute",
"mcp__claudemesh__graph_query",
"mcp__claudemesh__join_group",
"mcp__claudemesh__leave_group",
"mcp__claudemesh__list_collections",
"mcp__claudemesh__list_contexts",
"mcp__claudemesh__list_files",
"mcp__claudemesh__list_peers",
"mcp__claudemesh__list_scheduled",
"mcp__claudemesh__list_state",
"mcp__claudemesh__list_streams",
"mcp__claudemesh__list_tasks",
"mcp__claudemesh__mesh_execute",
"mcp__claudemesh__mesh_info",
"mcp__claudemesh__mesh_query",
"mcp__claudemesh__mesh_schema",
"mcp__claudemesh__message_status",
"mcp__claudemesh__ping_mesh",
"mcp__claudemesh__publish",
"mcp__claudemesh__recall",
"mcp__claudemesh__remember",
"mcp__claudemesh__schedule_reminder",
"mcp__claudemesh__send_message",
"mcp__claudemesh__set_state",
"mcp__claudemesh__set_status",
"mcp__claudemesh__set_summary",
"mcp__claudemesh__share_context",
"mcp__claudemesh__share_file",
"mcp__claudemesh__subscribe",
"mcp__claudemesh__vector_delete",
"mcp__claudemesh__vector_search",
"mcp__claudemesh__vector_store",
];
/**
* Pre-approve all claudemesh MCP tools in allowedTools.
* Merges into any existing list — never overwrites other entries.
* Returns which tools were added vs already present.
*/
function installAllowedTools(): { added: string[]; unchanged: number } {
const settings = readClaudeSettings();
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
if (toAdd.length > 0) {
settings.allowedTools = [...Array.from(existing), ...toAdd];
writeClaudeSettings(settings);
}
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
}
/**
* Remove claudemesh tools from allowedTools.
* Leaves all other entries intact. Returns count removed.
*/
function uninstallAllowedTools(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const existing = (settings.allowedTools as string[] | undefined) ?? [];
const toolSet = new Set(CLAUDEMESH_TOOLS);
const kept = existing.filter((t) => !toolSet.has(t));
const removed = existing.length - kept.length;
if (removed > 0) {
settings.allowedTools = kept;
writeClaudeSettings(settings);
}
return removed;
}
/**
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
* idempotent on the command string. Returns counts for reporting.
@@ -321,6 +408,26 @@ export function runInstall(args: string[] = []): void {
),
);
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
// --dangerously-skip-permissions just to call mesh tools.
try {
const { added, unchanged } = installAllowedTools();
if (added.length > 0) {
console.log(
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
);
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
console.log(dim(` Your existing allowedTools entries were preserved.`));
} else {
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
if (!skipHooks) {
try {
@@ -345,12 +452,35 @@ export function runInstall(args: string[] = []): void {
console.log(dim("· Hooks skipped (--no-hooks)"));
}
// Check if user has any meshes joined — nudge them if not.
let hasMeshes = false;
try {
const meshConfig = loadConfig();
hasMeshes = meshConfig.meshes.length > 0;
} catch {
// Config missing or corrupt — treat as no meshes.
}
console.log("");
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
console.log("");
console.log(
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
);
if (!hasMeshes) {
console.log("");
console.log(yellow("No meshes joined.") + " To connect with peers:");
console.log(
` ${bold("claudemesh join <invite-url>")}` +
dim(" — join an existing mesh"),
);
console.log(
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
);
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
);
}
console.log("");
console.log(
yellow("⚠ For real-time push messages from peers, launch with:"),
@@ -375,6 +505,20 @@ export function runUninstall(): void {
console.log(`· MCP server "${MCP_NAME}" not present`);
}
// allowedTools
try {
const removed = uninstallAllowedTools();
if (removed > 0) {
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
} else {
console.log("· No claudemesh allowedTools to remove");
}
} catch (e) {
console.error(
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks
try {
const removed = uninstallHooks();

View File

@@ -14,7 +14,10 @@ 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];
@@ -78,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

@@ -1,9 +1,12 @@
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Flags are defined in index.ts (citty command) — that is the source of
* truth. This file receives already-parsed flags and rawArgs.
*
* Flow:
* 1. Parse --name, --join, --mesh, --quiet flags
* 2. If --join: run join flow first (accepts token or URL)
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first
* 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
@@ -11,61 +14,29 @@
*/
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir, hostname } from "node:os";
import { randomUUID } from "node:crypto";
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
import { tmpdir, hostname, homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh } from "../state/config";
import { generateKeypair } from "../crypto/keypair";
import { enrollWithBroker } from "../invite/enroll";
import { parseInviteLink } from "../invite/parse";
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
import { BrokerClient } from "../ws/client";
// --- Arg parsing ---
interface LaunchArgs {
name: string | null;
joinLink: string | null;
meshSlug: string | null;
quiet: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
joinLink: null,
meshSlug: null,
quiet: 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 === "--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 === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
name?: string;
role?: string;
groups?: string;
join?: string;
mesh?: string;
"message-mode"?: string;
"system-prompt"?: string;
resume?: string;
continue?: boolean;
yes?: boolean;
quiet?: boolean;
}
// --- Interactive mesh picker ---
@@ -94,18 +65,91 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
});
}
// --- 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 run with --dangerously-skip-permissions, bypassing");
console.log(" ALL permission prompts — not just claudemesh tools.");
console.log(" Peers exchange text only — no file access, no tool calls.");
console.log("");
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
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): void {
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): 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} on ${meshSlug}`));
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
console.log(rule);
console.log("Peer messages arrive as <channel> reminders in real-time.");
if (messageMode === "push") {
console.log("Peer messages arrive as <channel> reminders in real-time.");
} else if (messageMode === "inbox") {
console.log("Peer messages held in inbox. Use check_messages to read.");
} else {
console.log("Messages off. Use check_messages to poll manually.");
}
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule);
@@ -114,8 +158,28 @@ function printBanner(name: string, meshSlug: string): void {
// --- Main ---
export async function runLaunch(extraArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs);
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
// Extract args that follow "--" — passed straight through to claude.
const dashIdx = rawArgs.indexOf("--");
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
// Normalise flags into the internal shape used below.
const args = {
name: flags.name ?? null,
role: flags.role ?? null,
groups: flags.groups ?? null,
joinLink: flags.join ?? null,
meshSlug: flags.mesh ?? null,
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
? flags["message-mode"] as "push" | "inbox" | "off"
: null),
systemPrompt: flags["system-prompt"] ?? null,
resume: flags.resume ?? null,
continueSession: flags.continue ?? false,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first.
if (args.joinLink) {
@@ -153,10 +217,85 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
// 2. Load config, pick mesh.
const config = loadConfig();
let justSynced = false;
if (config.meshes.length === 0 && !args.joinLink) {
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 code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
console.log(` Opening browser to sign in...\n`);
const opened = await openBrowser(url);
if (!opened) {
console.log(` Couldn't open browser automatically.`);
}
console.log(` ${dim(`Visit: ${url}`)}`);
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("\n Timed out waiting for sign-in.");
process.exit(1);
}
// Generate keypair and sync with broker
const { generateKeypair } = await import("../crypto/keypair");
const keypair = await generateKeypair();
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
const { syncWithBroker } = await import("../auth/sync-with-broker");
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
// Write all meshes to config
const { saveConfig } = await import("../state/config");
for (const m of result.meshes) {
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
}
config.accountId = result.account_id;
saveConfig(config);
justSynced = true;
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
}
if (config.meshes.length === 0) {
console.error(
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
);
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
process.exit(1);
}
@@ -174,16 +313,111 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
mesh = await pickMesh(config.meshes);
}
// 3. Set display name. Uses existing member identity — the broker
// creates a separate presence row per session (sessionId + pid)
// and stores the per-session displayName override.
// 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}`;
// 4. Write session config to tmpdir (same mesh, same keypair).
// 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) : [];
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
if (!args.quiet && !justSynced) {
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 (args.messageMode === null) {
console.log("\n Message mode:");
console.log(" 1) Push (real-time, peers can interrupt your work)");
console.log(" 2) Inbox (held until you check, notification only)");
console.log(" 3) Off (tools only, no messages)");
console.log("");
const answer = await askLine(" Choice [1]: ");
const choice = parseInt(answer || "1", 10);
if (choice === 2) messageMode = "inbox";
else if (choice === 3) messageMode = "off";
else messageMode = "push";
}
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 */ }
// Clean up stale mesh MCP entries from crashed sessions
try {
const claudeConfigPath = join(homedir(), ".claude.json");
if (existsSync(claudeConfigPath)) {
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
let cleaned = 0;
for (const key of Object.keys(mcpServers)) {
if (!key.startsWith("mesh:")) continue;
const meta = mcpServers[key]?._meshSession;
if (!meta?.pid) continue;
// Check if the PID is still alive
try {
process.kill(meta.pid, 0); // signal 0 = check existence
} catch {
// PID is dead — remove stale entry
delete mcpServers[key];
cleaned++;
}
}
if (cleaned > 0) {
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
}
}
} catch { /* best effort */ }
// --- Fetch deployed services for native MCP entries ---
let serviceCatalog: Array<{
name: string;
description: string;
status: string;
tools: Array<{ name: string; description: string; inputSchema: object }>;
deployed_by: string;
}> = [];
try {
const tmpClient = new BrokerClient(mesh, { displayName });
await tmpClient.connect();
// Wait briefly for hello_ack with service catalog
await new Promise(r => setTimeout(r, 2000));
serviceCatalog = tmpClient.serviceCatalog;
tmpClient.close();
} catch {
// Non-fatal — launch without native service entries
if (!args.quiet) {
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
}
}
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
writeFileSync(
join(tmpDir, "config.json"),
@@ -191,14 +425,97 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
"utf-8",
);
// 5. Banner.
if (!args.quiet) printBanner(displayName, mesh.slug);
// 5. Banner + permission confirmation.
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
// Auto-permissions confirmation — needed for autonomous peer messaging.
if (!args.skipPermConfirm) {
await confirmPermissions();
}
}
// --- Install native MCP entries for deployed mesh services ---
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
if (serviceCatalog.length > 0) {
const claudeConfigPath = join(homedir(), ".claude.json");
// Read-modify-write: only touch mesh:* entries in mcpServers
let claudeConfig: Record<string, unknown> = {};
try {
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
} catch {
claudeConfig = {};
}
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
// Session-scoped key: mesh:<service>:<sessionId>
const sessionTag = `${process.pid}`;
for (const svc of serviceCatalog) {
if (svc.status !== "running") continue;
const entryKey = `mesh:${svc.name}:${sessionTag}`;
const entry = {
command: "claudemesh",
args: ["mcp", "--service", svc.name],
env: {
CLAUDEMESH_CONFIG_DIR: tmpDir,
},
_meshSession: {
pid: process.pid,
meshSlug: mesh.slug,
serviceName: svc.name,
createdAt: new Date().toISOString(),
},
};
mcpServers[entryKey] = entry;
meshMcpEntries.push({ key: entryKey, entry });
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
if (!args.quiet && meshMcpEntries.length > 0) {
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
for (const { key } of meshMcpEntries) {
const svcName = key.split(":")[1];
const svc = serviceCatalog.find(s => s.name === svcName);
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
}
console.log("");
}
}
// 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]!);
}
// --dangerously-skip-permissions is only added when the user explicitly
// passes -y / --yes. Without it, claudemesh tools still work because
// `claudemesh install` pre-approves them via allowedTools in settings.json.
// This keeps permissions tight for multi-person meshes.
// Session identity: --resume reuses existing session, otherwise generate new.
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
const isResume = args.resume !== null || args.continueSession;
const claudeSessionId = isResume ? undefined : randomUUID();
// 6. Spawn claude with ephemeral config + dev channel + display name.
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...args.claudeArgs,
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
...(args.resume ? ["--resume", args.resume] : []),
...(args.continueSession ? ["--continue"] : []),
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered,
];
const isWindows = process.platform === "win32";
@@ -209,11 +526,29 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
...(role ? { CLAUDEMESH_ROLE: role } : {}),
},
});
// 7. Cleanup on exit.
const cleanup = (): void => {
// Remove mesh MCP entries from ~/.claude.json
if (meshMcpEntries.length > 0) {
try {
const claudeConfigPath = join(homedir(), ".claude.json");
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
for (const { key } of meshMcpEntries) {
delete mcpServers[key];
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ }
}
// Existing tmpdir cleanup
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {

View File

@@ -0,0 +1,63 @@
/**
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
* `claudemesh recall <query>` — search mesh memory.
*
* Useful for AI agents using bash when the MCP server isn't active.
*/
import { withMesh } from "./connect";
export interface MemoryFlags {
mesh?: string;
tags?: string;
json?: boolean;
}
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
const tags = flags.tags
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
: undefined;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const id = await client.remember(content, tags);
if (flags.json) {
console.log(JSON.stringify({ id, content, tags }));
return;
}
if (id) {
console.log(`✓ Remembered (${id.slice(0, 8)})`);
} else {
console.error("✗ Failed to store memory");
process.exit(1);
}
});
}
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const memories = await client.recall(query);
if (flags.json) {
console.log(JSON.stringify(memories, null, 2));
return;
}
if (memories.length === 0) {
console.log(dim("No memories found."));
return;
}
for (const m of memories) {
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
console.log(` ${m.content}`);
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
console.log("");
}
});
}

View File

@@ -0,0 +1,55 @@
/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Connects, fetches the peer list, prints it, disconnects.
*/
import { withMesh } from "./connect";
export interface PeersFlags {
mesh?: string;
json?: boolean;
}
export async function runPeers(flags: PeersFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const peers = await client.listPeers();
if (flags.json) {
console.log(JSON.stringify(peers, null, 2));
return;
}
if (peers.length === 0) {
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
return;
}
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
console.log("");
for (const p of peers) {
const groups = p.groups.length
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const statusIcon = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
const summary = p.summary ? dim(` ${p.summary}`) : "";
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
if (cwdStr) console.log(` ${cwdStr}`);
}
console.log("");
});
}

View File

@@ -0,0 +1,114 @@
/**
* `claudemesh profile` — view or edit your member profile.
*
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
* on the server. Changes are pushed to active sessions in real-time.
*/
import { loadConfig } from "../state/config";
import { BrokerClient } from "../ws/client";
export interface ProfileFlags {
mesh?: string;
"role-tag"?: string;
groups?: string;
"message-mode"?: string;
name?: string;
member?: string; // admin only: edit another member
json?: boolean;
}
export async function runProfile(flags: ProfileFlags): 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 config = loadConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
// Pick mesh
const mesh = flags.mesh
? config.meshes.find(m => m.slug === flags.mesh)
: config.meshes[0]!;
if (!mesh) {
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
process.exit(1);
}
// Derive broker HTTP URL from WSS URL
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
if (hasEdits) {
// PATCH member profile
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
const body: Record<string, unknown> = {};
if (flags.name !== undefined) body.displayName = flags.name;
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
if (flags.groups !== undefined) {
body.groups = flags.groups.split(",").map(s => {
const [name, role] = s.trim().split(":");
return role ? { name: name!, role } : { name: name! };
});
}
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Member-Id": mesh.memberId,
},
body: JSON.stringify(body),
});
const result = await res.json() as Record<string, unknown>;
if (flags.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.ok) {
console.log(green("✓ Profile updated"));
const member = result.member as Record<string, unknown>;
printProfile(member, dim);
} else {
console.error(`Error: ${result.error}`);
process.exit(1);
}
} else {
// GET members list, show current user's profile
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
if (!result.ok) {
console.error(`Error: ${result.error}`);
process.exit(1);
}
const me = result.members?.find(m => m.id === mesh.memberId);
if (flags.json) {
console.log(JSON.stringify(me ?? {}, null, 2));
} else if (me) {
printProfile(me, dim);
} else {
console.log("Member not found in mesh.");
}
}
}
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
const groupStr = groups?.length
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
: dim("(none)");
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
console.log(` Groups: ${groupStr}`);
console.log(` Messages: ${member.messageMode ?? "push"}`);
console.log(` Access: ${member.permission ?? "member"}`);
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
}

View File

@@ -0,0 +1,142 @@
/**
* `claudemesh remind <message> --in <duration> | --at <time>`
* `claudemesh remind list`
* `claudemesh remind cancel <id>`
*
* Human-facing interface to the broker's scheduled message delivery.
*/
import { withMesh } from "./connect";
export interface RemindFlags {
mesh?: string;
in?: string; // e.g. "2h", "30m", "90s"
at?: string; // ISO or HH:MM
cron?: string; // 5-field cron expression for recurring
to?: string; // default: self
json?: boolean;
}
function parseDuration(raw: string): number | null {
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
if (!m) return null;
const n = parseFloat(m[1]!);
const unit = (m[2] ?? "s").toLowerCase();
if (unit.startsWith("d")) return n * 86_400_000;
if (unit.startsWith("h")) return n * 3_600_000;
if (unit.startsWith("m")) return n * 60_000;
return n * 1_000;
}
function parseDeliverAt(flags: RemindFlags): number | null {
if (flags.in) {
const ms = parseDuration(flags.in);
if (ms === null) return null;
return Date.now() + ms;
}
if (flags.at) {
// Try HH:MM first
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
if (hm) {
const now = new Date();
const target = new Date(now);
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
return target.getTime();
}
const ts = Date.parse(flags.at);
return isNaN(ts) ? null : ts;
}
return null;
}
export async function runRemind(
flags: RemindFlags,
positional: string[],
): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const action = positional[0];
// claudemesh remind list
if (action === "list") {
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const scheduled = await client.listScheduled();
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
for (const m of scheduled) {
const when = new Date(m.deliverAt).toLocaleString();
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
console.log(` ${bold(m.id.slice(0, 8))}${to} at ${when}`);
console.log(` ${dim(m.message.slice(0, 80))}`);
console.log("");
}
});
return;
}
// claudemesh remind cancel <id>
if (action === "cancel") {
const id = positional[1];
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const ok = await client.cancelScheduled(id);
if (ok) console.log(`✓ Cancelled ${id}`);
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
});
return;
}
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
const message = action ?? positional.join(" ");
if (!message) {
console.error("Usage: claudemesh remind <message> --in <duration>");
console.error(" claudemesh remind <message> --at <time>");
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
console.error(" claudemesh remind list");
console.error(" claudemesh remind cancel <id>");
process.exit(1);
}
const isCron = !!flags.cron;
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
if (!isCron && deliverAt === null) {
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
process.exit(1);
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Determine target: --to flag or self
let targetSpec: string;
if (flags.to && flags.to !== "self") {
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
targetSpec = flags.to;
} else {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
if (!match) {
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
} else {
targetSpec = client.getSessionPubkey() ?? "*";
}
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
if (flags.json) { console.log(JSON.stringify(result)); return; }
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
if (isCron) {
const nextFire = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
} else {
const when = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
}
});
}

View File

@@ -0,0 +1,51 @@
/**
* `claudemesh send <to> <message>` — send a message to a peer or group.
*
* <to> can be:
* - a display name ("Mou")
* - a pubkey hex ("abc123...")
* - @group ("@flexicar")
* - * (broadcast to all)
*/
import { withMesh } from "./connect";
import type { Priority } from "../ws/client";
export interface SendFlags {
mesh?: string;
priority?: string;
}
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
const priority: Priority =
flags.priority === "now" ? "now"
: flags.priority === "low" ? "low"
: "next";
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Resolve display name → pubkey for direct messages.
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
let targetSpec = to;
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
// Treat as display name — look up pubkey via list_peers.
const peers = await client.listPeers();
const match = peers.find(
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
);
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
const result = await client.send(targetSpec, message, priority);
if (result.ok) {
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
} else {
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,75 @@
/**
* `claudemesh state get <key>` — read a shared state value
* `claudemesh state set <key> <value>` — write a shared state value
* `claudemesh state list` — list all state entries
*/
import { withMesh } from "./connect";
export interface StateFlags {
mesh?: string;
json?: boolean;
}
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const entry = await client.getState(key);
if (!entry) {
console.log(dim(`(not set)`));
return;
}
if (flags.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
console.log(val);
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
});
}
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed);
console.log(`${key} = ${JSON.stringify(parsed)}`);
});
}
export async function runStateList(flags: StateFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const entries = await client.listState();
if (flags.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log(dim(`No state on mesh "${mesh.slug}".`));
return;
}
for (const e of entries) {
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
console.log(`${bold(e.key)}: ${val}`);
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
}
});
}

View File

@@ -0,0 +1,88 @@
/**
* `claudemesh sync` — re-sync meshes from dashboard account.
*
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
* merges new meshes into local config.
*/
import { createInterface } from "node:readline";
import { hostname } from "node:os";
import { loadConfig, saveConfig } from "../state/config";
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
import { generateKeypair } from "../crypto/keypair";
export async function runSync(args: { force?: boolean }): 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 config = loadConfig();
const code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`Opening browser to sync meshes...`);
console.log(dim(`Visit: ${url}`));
await openBrowser(url);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question("Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("Timed out waiting for sign-in.");
process.exit(1);
}
// Use existing keypair from first mesh, or generate new
const keypair = config.meshes.length > 0
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
: await generateKeypair();
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
// Merge: add new meshes, skip duplicates
let added = 0;
for (const m of result.meshes) {
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
added++;
}
config.accountId = result.account_id;
saveConfig(config);
if (added > 0) {
console.log(green(`✓ Added ${added} new mesh(es)`));
} else {
console.log(`Already up to date (${config.meshes.length} meshes)`);
}
}

View File

@@ -0,0 +1,90 @@
/**
* File encryption for claudemesh E2E file sharing.
*
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
* Key opening: crypto_box_seal_open with own X25519 keypair.
*/
import { ensureSodium } from "./keypair";
export interface EncryptedFile {
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
nonce: string; // base64 24-byte nonce
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
}
/**
* Encrypt file bytes with a fresh random symmetric key.
* Returns ciphertext, nonce (base64), and the plaintext Kf.
*/
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
const sodium = await ensureSodium();
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return {
ciphertext,
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
key,
};
}
/**
* Decrypt file bytes with the symmetric key Kf.
* Returns null if decryption fails.
*/
export async function decryptFile(
ciphertext: Uint8Array,
nonceB64: string,
key: Uint8Array,
): Promise<Uint8Array | null> {
const sodium = await ensureSodium();
try {
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
} catch {
return null;
}
}
/**
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
* Returns base64 sealed box.
*/
export async function sealKeyForPeer(
kf: Uint8Array,
recipientPubkeyHex: string,
): Promise<string> {
const sodium = await ensureSodium();
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
}
/**
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
* Returns the 32-byte Kf or null if decryption fails.
*/
export async function openSealedKey(
sealedB64: string,
myPubkeyHex: string,
mySecretKeyHex: string,
): Promise<Uint8Array | null> {
const sodium = await ensureSodium();
try {
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(myPubkeyHex),
);
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(mySecretKeyHex),
);
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
} catch {
return null;
}
}

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,13 +1,15 @@
/**
* claudemesh-cli entry point.
*
* Uses citty to define commands and flags. --help is generated from
* the command definitions — the flag list here IS the documentation.
*
* Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport)
* - `claudemesh <subcommand>` → CLI subcommand
*
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
*/
import { defineCommand, runMain } from "citty";
import { startMcpServer } from "./mcp/server";
import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join";
@@ -19,98 +21,319 @@ import { runLaunch } from "./commands/launch";
import { runStatus } from "./commands/status";
import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome";
import { runPeers } from "./commands/peers";
import { runSend } from "./commands/send";
import { runInbox } from "./commands/inbox";
import { runStateGet, runStateSet, runStateList } from "./commands/state";
import { runRemember, runRecall } from "./commands/memory";
import { runInfo } from "./commands/info";
import { runRemind } from "./commands/remind";
import { runCreate } from "./commands/create";
import { runSync } from "./commands/sync";
import { runProfile, type ProfileFlags } from "./commands/profile";
import { connectTelegram } from "./commands/connect-telegram";
import { disconnectTelegram } from "./commands/disconnect-telegram";
import { VERSION } from "./version";
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
Usage:
claudemesh <command> [args]
Commands:
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)
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
CLAUDEMESH_DEBUG=1 Verbose logging
`;
const cmd = process.argv[2];
const args = process.argv.slice(3);
async function main(): Promise<void> {
switch (cmd) {
case "mcp":
await startMcpServer();
return;
case "install":
runInstall(args);
return;
case "uninstall":
runUninstall();
return;
case "hook":
await runHook(args);
return;
case "launch":
await runLaunch(args);
return;
case "join":
await runJoin(args);
return;
case "list":
runList();
return;
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":
console.log(HELP);
return;
case undefined:
runWelcome();
return;
default:
console.error(`Unknown command: ${cmd}`);
console.error("Run `claudemesh --help` for usage.");
process.exit(1);
}
}
main().catch((e) => {
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
const launch = defineCommand({
meta: {
name: "launch",
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
},
args: {
name: {
type: "string",
description: "Display name visible to other peers",
},
role: {
type: "string",
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
},
groups: {
type: "string",
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
},
mesh: {
type: "string",
description: "Mesh slug (interactive picker if omitted and >1 joined)",
},
join: {
type: "string",
description: "Join a mesh via invite URL before launching",
},
"message-mode": {
type: "string",
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
},
"system-prompt": {
type: "string",
description: "Custom system prompt for this Claude session",
},
yes: {
type: "boolean",
alias: "y",
description: "Skip the --dangerously-skip-permissions confirmation",
default: false,
},
resume: {
type: "string",
alias: "r",
description: "Resume a previous Claude Code session by ID, or pass `true` for interactive picker",
},
continue: {
type: "boolean",
alias: "c",
description: "Continue the most recent conversation in this directory",
default: false,
},
quiet: {
type: "boolean",
description: "Suppress banner and interactive prompts",
default: false,
},
},
run({ args, rawArgs }) {
// Forward to the existing launch runner, preserving -- passthrough to claude.
return runLaunch(args, rawArgs);
},
});
const install = defineCommand({
meta: {
name: "install",
description: "Register MCP server and status hooks with Claude Code",
},
args: {
"no-hooks": {
type: "boolean",
description: "Register MCP server only, skip hooks",
default: false,
},
},
run({ rawArgs }) {
runInstall(rawArgs);
},
});
const join = defineCommand({
meta: {
name: "join",
description: "Join a mesh via invite URL or token",
},
args: {
url: {
type: "positional",
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
required: true,
},
},
run({ args }) {
return runJoin([args.url]);
},
});
const leave = defineCommand({
meta: {
name: "leave",
description: "Leave a joined mesh and remove its local keypair",
},
args: {
slug: {
type: "positional",
description: "Mesh slug to leave (see `claudemesh list`)",
required: true,
},
},
run({ args }) {
runLeave([args.slug]);
},
});
const main = defineCommand({
meta: {
name: "claudemesh",
version: VERSION,
description: "Peer mesh for Claude Code sessions",
},
subCommands: {
launch,
create: defineCommand({
meta: { name: "create", description: "Create a new mesh from a template" },
args: {
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
},
run({ args }) { runCreate(args); },
}),
install,
uninstall: defineCommand({
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
run() { runUninstall(); },
}),
join,
list: defineCommand({
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
run() { runList(); },
}),
leave,
peers: defineCommand({
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runPeers(args); },
}),
send: defineCommand({
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
args: {
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
message: { type: "positional", description: "Message text", required: true },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
},
async run({ args }) { await runSend(args, args.to, args.message); },
}),
inbox: defineCommand({
meta: { name: "inbox", description: "Drain pending inbound messages" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
},
async run({ args }) {
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
},
}),
state: defineCommand({
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
args: {
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
key: { type: "positional", description: "State key (required for `get` and `set`)" },
value: { type: "positional", description: "Value to store (required for `set`)" },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
if (args.action === "list") {
await runStateList(args);
} else if (args.action === "get") {
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
await runStateGet(args, args.key);
} else if (args.action === "set") {
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
await runStateSet(args, args.key, args.value);
} else {
console.error(`Unknown action "${args.action}". Use: get, set, list`);
process.exit(1);
}
},
}),
info: defineCommand({
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runInfo(args); },
}),
remember: defineCommand({
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
args: {
content: { type: "positional", description: "Text to store", required: true },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runRemember(args, args.content); },
}),
recall: defineCommand({
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
args: {
query: { type: "positional", description: "Full-text search query", required: true },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runRecall(args, args.query); },
}),
remind: defineCommand({
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
args: {
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args, rawArgs }) {
// Collect positional args from rawArgs (before any flags)
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
await runRemind(args, positionals);
},
}),
sync: defineCommand({
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
args: {
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
},
async run({ args }) { await runSync(args); },
}),
profile: defineCommand({
meta: { name: "profile", description: "View or edit your member profile" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
name: { type: "string", description: "Set display name" },
member: { type: "string", description: "Edit another member (admin only)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runProfile(args as ProfileFlags); },
}),
status: defineCommand({
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
async run() { await runStatus(); },
}),
doctor: defineCommand({
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
async run() { await runDoctor(); },
}),
mcp: defineCommand({
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
async run() { await startMcpServer(); },
}),
"seed-test-mesh": defineCommand({
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
}),
hook: defineCommand({
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
async run({ rawArgs }) { await runHook(rawArgs); },
}),
connect: defineCommand({
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
async run({ args }) {
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
},
}),
disconnect: defineCommand({
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
async run({ args }) {
if (args.target === "telegram") await disconnectTelegram();
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
},
}),
},
run() {
runWelcome();
},
});
runMain(main);

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;
@@ -96,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;
@@ -140,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 };
}
/**
@@ -155,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;

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
{
name: "send_message",
description:
"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, `#channel`, or `*` for broadcast. `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",
oneOf: [
{ type: "string", description: "Peer name, pubkey, @group" },
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
],
description: "Single target or array of targets",
},
message: { type: "string", description: "Message text" },
priority: {
@@ -44,6 +47,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 +96,925 @@ export const TOOLS: Tool[] = [
required: ["status"],
},
},
{
name: "set_visible",
description:
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
inputSchema: {
type: "object",
properties: {
visible: {
type: "boolean",
description: "true to be visible (default), false to hide",
},
},
required: ["visible"],
},
},
{
name: "set_profile",
description:
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
inputSchema: {
type: "object",
properties: {
avatar: {
type: "string",
description: "Emoji or URL for your avatar",
},
title: {
type: "string",
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
},
bio: {
type: "string",
description: "One-liner about yourself",
},
capabilities: {
type: "array",
items: { type: "string" },
description: "What you can help with",
},
},
},
},
{
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"],
},
},
// --- File tools ---
{
name: "share_file",
description:
"Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Local file path to share" },
name: {
type: "string",
description: "Display name (defaults to filename)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
to: {
type: "string",
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
},
},
required: ["path"],
},
},
{
name: "get_file",
description: "Download a shared file to a local path.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
save_to: {
type: "string",
description: "Local path to save the file",
},
},
required: ["id", "save_to"],
},
},
{
name: "list_files",
description: "List files shared in the mesh.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search by name or tags" },
from: { type: "string", description: "Filter by uploader name" },
},
},
},
{
name: "file_status",
description: "Check who has accessed a shared file.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
{
name: "delete_file",
description: "Remove a shared file from the mesh.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
{
name: "grant_file_access",
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
inputSchema: {
type: "object",
properties: {
fileId: { type: "string", description: "File ID" },
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
},
required: ["fileId", "to"],
},
},
// --- Vector tools ---
{
name: "vector_store",
description:
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
inputSchema: {
type: "object",
properties: {
collection: { type: "string", description: "Collection name" },
text: { type: "string", description: "Text to embed and store" },
metadata: {
type: "object",
description: "Optional metadata to attach",
},
},
required: ["collection", "text"],
},
},
{
name: "vector_search",
description: "Semantic search over stored embeddings in a collection.",
inputSchema: {
type: "object",
properties: {
collection: { type: "string", description: "Collection name" },
query: { type: "string", description: "Search query text" },
limit: {
type: "number",
description: "Max results (default: 10)",
},
},
required: ["collection", "query"],
},
},
{
name: "vector_delete",
description: "Remove an embedding from a collection.",
inputSchema: {
type: "object",
properties: {
collection: { type: "string", description: "Collection name" },
id: { type: "string", description: "Embedding ID to delete" },
},
required: ["collection", "id"],
},
},
{
name: "list_collections",
description: "List vector collections in this mesh.",
inputSchema: { type: "object", properties: {} },
},
// --- Graph tools ---
{
name: "graph_query",
description:
"Run a read-only Cypher query on the per-mesh Neo4j database.",
inputSchema: {
type: "object",
properties: {
cypher: { type: "string", description: "Cypher MATCH query" },
},
required: ["cypher"],
},
},
{
name: "graph_execute",
description:
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
inputSchema: {
type: "object",
properties: {
cypher: { type: "string", description: "Cypher write query" },
},
required: ["cypher"],
},
},
// --- Mesh Database tools ---
{
name: "mesh_query",
description:
"Run a SELECT query on the per-mesh shared database.",
inputSchema: {
type: "object",
properties: {
sql: { type: "string", description: "SQL SELECT query" },
},
required: ["sql"],
},
},
{
name: "mesh_execute",
description:
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
inputSchema: {
type: "object",
properties: {
sql: { type: "string", description: "SQL statement" },
},
required: ["sql"],
},
},
{
name: "mesh_schema",
description:
"List tables and columns in the per-mesh shared database.",
inputSchema: { type: "object", properties: {} },
},
// --- Stream tools ---
{
name: "create_stream",
description:
"Create a real-time data stream in the mesh.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Stream name" },
},
required: ["name"],
},
},
{
name: "publish",
description:
"Push data to a stream. Subscribers receive it in real-time.",
inputSchema: {
type: "object",
properties: {
stream: { type: "string", description: "Stream name" },
data: { description: "Any JSON data to publish" },
},
required: ["stream", "data"],
},
},
{
name: "subscribe",
description:
"Subscribe to a stream. Data pushes arrive as channel notifications.",
inputSchema: {
type: "object",
properties: {
stream: { type: "string", description: "Stream name" },
},
required: ["stream"],
},
},
{
name: "list_streams",
description:
"List active streams in the mesh.",
inputSchema: { type: "object", properties: {} },
},
// --- Context tools ---
{
name: "share_context",
description:
"Share your session understanding with the mesh. Call after exploring a codebase area.",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Summary of what you explored/learned",
},
files_read: {
type: "array",
items: { type: "string" },
description: "File paths you read",
},
key_findings: {
type: "array",
items: { type: "string" },
description: "Key findings or insights",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["summary"],
},
},
{
name: "get_context",
description:
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (file path, topic, etc.)",
},
},
required: ["query"],
},
},
{
name: "list_contexts",
description: "See what all peers currently know about the codebase.",
inputSchema: { type: "object", properties: {} },
},
// --- Task tools ---
{
name: "create_task",
description: "Create a work item for the mesh.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Task title" },
assignee: {
type: "string",
description: "Peer name to assign (optional)",
},
priority: {
type: "string",
enum: ["low", "normal", "high", "urgent"],
description: "Priority level (default: normal)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["title"],
},
},
{
name: "claim_task",
description: "Claim an unclaimed task to take ownership.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Task ID" },
},
required: ["id"],
},
},
{
name: "complete_task",
description: "Mark a task as done with an optional result summary.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Task ID" },
result: {
type: "string",
description: "Summary of what was done",
},
},
required: ["id"],
},
},
{
name: "list_tasks",
description: "List tasks filtered by status and/or assignee.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["open", "claimed", "completed"],
description: "Filter by status",
},
assignee: {
type: "string",
description: "Filter by assignee name",
},
},
},
},
// --- Scheduled messages ---
{
name: "schedule_reminder",
description:
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
inputSchema: {
type: "object",
properties: {
message: { type: "string", description: "Message or reminder text" },
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
to: {
type: "string",
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
},
},
required: ["message"],
},
},
{
name: "list_scheduled",
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
inputSchema: { type: "object", properties: {} },
},
{
name: "cancel_scheduled",
description: "Cancel a pending scheduled message before it fires.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Scheduled message ID" },
},
required: ["id"],
},
},
// --- Mesh info ---
{
name: "mesh_info",
description:
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
inputSchema: { type: "object", properties: {} },
},
// --- Stats ---
{
name: "mesh_stats",
description:
"View resource usage stats for all peers: messages sent/received, tool calls, uptime, errors.",
inputSchema: { type: "object", properties: {} },
},
// --- MCP Proxy ---
{
name: "mesh_mcp_register",
description:
"Register an MCP server with the mesh. Other peers can invoke its tools through the mesh without restarting their sessions. Provide the server name, description, and full tool definitions.",
inputSchema: {
type: "object",
properties: {
server_name: { type: "string", description: "Unique name for the MCP server (e.g. 'github', 'jira')" },
description: { type: "string", description: "What this MCP server does" },
tools: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string" },
inputSchema: { type: "object", description: "JSON Schema for tool arguments" },
},
required: ["name", "description", "inputSchema"],
},
description: "Tool definitions to expose",
},
persistent: {
type: "boolean",
description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false",
},
},
required: ["server_name", "description", "tools"],
},
},
{
name: "mesh_mcp_list",
description:
"List MCP servers available in the mesh with their tools. Shows which peer hosts each server.",
inputSchema: { type: "object", properties: {} },
},
{
name: "mesh_tool_call",
description:
"Call a tool on a mesh-registered MCP server. Route: you -> broker -> hosting peer -> execute -> result back. Timeout: 30s.",
inputSchema: {
type: "object",
properties: {
server_name: { type: "string", description: "Name of the MCP server" },
tool_name: { type: "string", description: "Name of the tool to call" },
args: { type: "object", description: "Tool arguments (JSON object)" },
},
required: ["server_name", "tool_name"],
},
},
{
name: "mesh_mcp_remove",
description:
"Unregister an MCP server you previously registered with the mesh.",
inputSchema: {
type: "object",
properties: {
server_name: { type: "string", description: "Name of the MCP server to remove" },
},
required: ["server_name"],
},
},
// --- Simulation clock tools ---
{
name: "mesh_set_clock",
description:
"Set the simulation clock speed. x1 = real-time, x10 = 10x faster, x100 = 100x. Peers receive heartbeat ticks at the simulated rate.",
inputSchema: {
type: "object",
properties: {
speed: {
type: "number",
description: "Speed multiplier (1-100). x1 = tick every 60s, x10 = tick every 6s, x100 = tick every 600ms.",
},
},
required: ["speed"],
},
},
{
name: "mesh_pause_clock",
description:
"Pause the simulation clock. Ticks stop until resumed.",
inputSchema: { type: "object", properties: {} },
},
{
name: "mesh_resume_clock",
description:
"Resume a paused simulation clock.",
inputSchema: { type: "object", properties: {} },
},
{
name: "mesh_clock",
description:
"Get current simulation clock status: speed, tick count, simulated time.",
inputSchema: { type: "object", properties: {} },
},
// --- Skills ---
{
name: "share_skill",
description:
"Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." },
description: { type: "string", description: "Short description of what the skill does" },
instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." },
tags: {
type: "array",
items: { type: "string" },
description: "Tags for discoverability",
},
when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" },
allowed_tools: {
type: "array",
items: { type: "string" },
description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])",
},
model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" },
context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" },
agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" },
user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" },
argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" },
},
required: ["name", "description", "instructions"],
},
},
{
name: "get_skill",
description:
"Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Skill name to load" },
},
required: ["name"],
},
},
{
name: "list_skills",
description:
"Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search keyword (optional)" },
},
},
},
{
name: "remove_skill",
description:
"Remove a skill you published from the mesh.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Skill name to remove" },
},
required: ["name"],
},
},
// --- Diagnostics ---
{
name: "ping_mesh",
description:
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
inputSchema: {
type: "object",
properties: {
priorities: {
type: "array",
items: { type: "string", enum: ["now", "next", "low"] },
description: "Priorities to test (default: [\"now\", \"next\"])",
},
},
},
},
// --- Peer file sharing ---
{
name: "read_peer_file",
description:
"Read a file from another peer's project. Specify the peer (by name) and the file path relative to their working directory. The peer must be online and sharing files. Max file size: 1MB.",
inputSchema: {
type: "object",
properties: {
peer: { type: "string", description: "Peer display name or pubkey" },
path: { type: "string", description: "File path relative to peer's working directory" },
},
required: ["peer", "path"],
},
},
{
name: "list_peer_files",
description:
"List files in a peer's shared directory. Returns a tree of file names (not contents). The peer must be online and sharing files.",
inputSchema: {
type: "object",
properties: {
peer: { type: "string", description: "Peer display name or pubkey" },
path: { type: "string", description: "Directory path relative to peer's cwd (default: root)" },
pattern: { type: "string", description: "Glob-like filter pattern (e.g. '*.ts', 'src/*')" },
},
required: ["peer"],
},
},
// --- Webhooks ---
{
name: "create_webhook",
description:
"Create an inbound webhook. Returns a URL that external services (GitHub, CI/CD, monitoring) can POST to — the payload becomes a mesh message to all peers.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Webhook name (e.g. 'github-ci', 'datadog-alerts')",
},
},
required: ["name"],
},
},
{
name: "list_webhooks",
description: "List active webhooks for this mesh.",
inputSchema: { type: "object", properties: {} },
},
{
name: "delete_webhook",
description: "Deactivate a webhook.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Webhook name to deactivate" },
},
required: ["name"],
},
},
// --- Service deployment tools ---
{
name: "mesh_mcp_deploy",
description: "Deploy an MCP server to the mesh from a zip file or git repo. Runs on the broker VPS, persists across peer sessions. Default scope: private (only you).",
inputSchema: {
type: "object",
properties: {
server_name: { type: "string", description: "Unique name for the server in this mesh" },
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
git_url: { type: "string", description: "Git repo URL" },
git_branch: { type: "string", description: "Branch to clone (default: main)" },
npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" },
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
network_allow: { type: "array", items: { type: "string" }, description: "Allowed outbound hosts (default: none)" },
scope: { description: "Visibility: 'peer' (default), 'mesh', or {group/groups/role/peers}" },
},
required: ["server_name"],
},
},
{
name: "mesh_mcp_undeploy",
description: "Stop and remove a managed MCP server from the mesh.",
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
},
{
name: "mesh_mcp_update",
description: "Pull latest code and restart a git-sourced MCP server.",
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
},
{
name: "mesh_mcp_logs",
description: "View recent logs from a managed MCP server.",
inputSchema: { type: "object", properties: { server_name: { type: "string" }, lines: { type: "number", description: "Lines (default: 50, max: 1000)" } }, required: ["server_name"] },
},
{
name: "mesh_mcp_scope",
description: "Get or set the visibility scope of a deployed MCP server.",
inputSchema: { type: "object", properties: { server_name: { type: "string" }, scope: { description: "New scope to set. Omit to read current." } }, required: ["server_name"] },
},
{
name: "mesh_mcp_schema",
description: "Inspect tool schemas for a deployed MCP server.",
inputSchema: { type: "object", properties: { server_name: { type: "string" }, tool_name: { type: "string", description: "Specific tool (omit for all)" } }, required: ["server_name"] },
},
{
name: "mesh_mcp_catalog",
description: "List all deployed services in the mesh with status, scope, and tool count.",
inputSchema: { type: "object", properties: {} },
},
// --- Skill deployment tools ---
{
name: "mesh_skill_deploy",
description: "Deploy a multi-file skill bundle from a zip or git repo.",
inputSchema: { type: "object", properties: { file_id: { type: "string" }, git_url: { type: "string" }, git_branch: { type: "string" } } },
},
// --- Vault tools ---
{
name: "vault_set",
description: "Store an encrypted credential in your vault. Reference in mesh_mcp_deploy with $vault:<key>.",
inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string", description: "Secret value or local file path (for type=file)" }, type: { type: "string", enum: ["env", "file"] }, mount_path: { type: "string" }, description: { type: "string" } }, required: ["key", "value"] },
},
{
name: "vault_list",
description: "List your vault entries (keys and metadata only, no secret values).",
inputSchema: { type: "object", properties: {} },
},
{
name: "vault_delete",
description: "Remove a credential from your vault.",
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
},
// --- URL Watch tools ---
{
name: "mesh_watch",
description: "Watch a URL for changes. The broker polls it at the given interval and notifies you when the response changes. Works with any URL — websites (hash mode), JSON APIs (json mode), or status codes (status mode).",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to watch" },
mode: { type: "string", enum: ["hash", "json", "status"], description: "Detection mode: hash (SHA-256 of body), json (extract jsonpath value), status (HTTP status code). Default: hash" },
extract: { type: "string", description: "For json mode: dot path to extract (e.g. 'status' or 'data.deployments[0].status')" },
interval: { type: "number", description: "Poll interval in seconds (min: 5, default: 30)" },
notify_on: { type: "string", description: "When to notify: 'change' (default), 'match:<value>', 'not_match:<value>'" },
headers: { type: "object", description: "Optional HTTP headers (e.g. for auth)" },
label: { type: "string", description: "Human-readable label for this watch" },
},
required: ["url"],
},
},
{
name: "mesh_unwatch",
description: "Stop watching a URL.",
inputSchema: {
type: "object",
properties: { watch_id: { type: "string" } },
required: ["watch_id"],
},
},
{
name: "mesh_watches",
description: "List your active URL watches.",
inputSchema: { type: "object", properties: {} },
},
];

View File

@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs {
to: string; // peer name, pubkey, or #channel
to: string | string[]; // peer name, pubkey, @group, or array of targets
message: string;
priority?: Priority;
}
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
export interface SetStatusArgs {
status: PeerStatus;
}
// --- Service deployment types ---
export type ServiceScope =
| "peer"
| "mesh"
| { peers: string[] }
| { group: string }
| { groups: string[] }
| { role: string };
export interface ServiceInfo {
name: string;
type: "mcp" | "skill";
description: string;
status: string;
tool_count: number;
deployed_by: string;
scope: ServiceScope;
source_type: string;
runtime?: string;
created_at: string;
}
export interface ServiceToolSchema {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface VaultEntry {
key: string;
entry_type: "env" | "file";
mount_path?: string;
description?: string;
updated_at: string;
}
export interface MeshMcpDeployArgs {
server_name: string;
file_id?: string;
git_url?: string;
git_branch?: string;
env?: Record<string, string>;
runtime?: "node" | "python" | "bun";
memory_mb?: number;
network_allow?: string[];
scope?: ServiceScope;
}
export interface VaultSetArgs {
key: string;
value: string;
type?: "env" | "file";
mount_path?: string;
description?: string;
}

View File

@@ -15,38 +15,48 @@ 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`
role?: string; // per-session role tag (display + hello)
groups?: GroupEntry[];
messageMode?: "push" | "inbox" | "off";
accountId?: string; // linked dashboard user ID (from CLI sync flow)
}
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, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
} catch (e) {
throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -0,0 +1,17 @@
{
"name": "dev-team",
"description": "Software development team with frontend, backend, and devops groups",
"groups": [
{ "name": "frontend", "roles": ["lead", "member"] },
{ "name": "backend", "roles": ["lead", "member"] },
{ "name": "devops", "roles": ["lead", "member"] },
{ "name": "qa", "roles": ["lead", "member"] }
],
"stateKeys": {
"sprint": "current",
"deploy-frozen": "false",
"pr-queue": "[]"
},
"suggestedRoles": ["lead", "member", "reviewer"],
"systemPromptHint": "You are part of a dev team. Coordinate with @frontend, @backend, @devops groups. Check state keys for sprint status and deploy freezes before making changes."
}

View File

@@ -0,0 +1,30 @@
import devTeam from "./dev-team.json" with { type: "json" };
import research from "./research.json" with { type: "json" };
import opsIncident from "./ops-incident.json" with { type: "json" };
import simulation from "./simulation.json" with { type: "json" };
import personal from "./personal.json" with { type: "json" };
export interface MeshTemplate {
name: string;
description: string;
groups: Array<{ name: string; roles: string[] }>;
stateKeys: Record<string, string>;
suggestedRoles: string[];
systemPromptHint: string;
}
export const TEMPLATES: Record<string, MeshTemplate> = {
"dev-team": devTeam,
research,
"ops-incident": opsIncident,
simulation,
personal,
};
export function listTemplates(): MeshTemplate[] {
return Object.values(TEMPLATES);
}
export function getTemplate(name: string): MeshTemplate | undefined {
return TEMPLATES[name];
}

View File

@@ -0,0 +1,17 @@
{
"name": "ops-incident",
"description": "Incident response team with oncall, comms, and engineering groups",
"groups": [
{ "name": "oncall", "roles": ["primary", "secondary"] },
{ "name": "comms", "roles": ["lead", "scribe"] },
{ "name": "engineering", "roles": ["lead", "responder"] }
],
"stateKeys": {
"incident-status": "investigating",
"severity": "unknown",
"commander": "",
"timeline": "[]"
},
"suggestedRoles": ["commander", "primary-oncall", "scribe", "responder"],
"systemPromptHint": "INCIDENT MODE. Priority: now for all messages. Update incident-status state. Commander coordinates. Scribe maintains timeline. Engineering fixes."
}

View File

@@ -0,0 +1,11 @@
{
"name": "personal",
"description": "Private mesh for a single user — all sessions auto-join",
"groups": [],
"stateKeys": {
"focus": "",
"todos": "[]"
},
"suggestedRoles": [],
"systemPromptHint": "Personal workspace. All your Claude Code sessions share this mesh. Use state keys to track focus and todos across sessions."
}

View File

@@ -0,0 +1,16 @@
{
"name": "research",
"description": "Research and analysis team focused on deep investigation and knowledge sharing",
"groups": [
{ "name": "analysis", "roles": ["lead", "analyst"] },
{ "name": "writing", "roles": ["lead", "writer", "reviewer"] },
{ "name": "data", "roles": ["engineer", "analyst"] }
],
"stateKeys": {
"research-topic": "",
"phase": "exploration",
"findings-count": "0"
},
"suggestedRoles": ["lead", "analyst", "writer", "reviewer"],
"systemPromptHint": "You are part of a research team. Share findings via remember(), use recall() before starting new analysis. Coordinate phases through state keys."
}

View File

@@ -0,0 +1,17 @@
{
"name": "simulation",
"description": "Load testing simulation with configurable time multiplier and user personas",
"groups": [
{ "name": "personas", "roles": ["admin", "user", "customer"] },
{ "name": "observers", "roles": ["monitor", "analyst"] },
{ "name": "control", "roles": ["orchestrator"] }
],
"stateKeys": {
"clock-speed": "x1",
"sim-status": "paused",
"tick-count": "0",
"scenario": ""
},
"suggestedRoles": ["orchestrator", "persona", "monitor"],
"systemPromptHint": "SIMULATION MODE. Follow the clock-speed state for time multiplier. Act according to your persona role and the simulated time. Report actions to @observers."
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,21 @@ import type { Config, JoinedMesh } from "../state/config";
import { env } from "../env";
const clients = new Map<string, BrokerClient>();
let configDisplayName: string | undefined;
let configGroups: Config["groups"] = [];
/** 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();
// Auto-join groups declared at launch time (--groups flag or config).
for (const g of configGroups ?? []) {
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
}
} catch {
// Connect failed → client is in "reconnecting" state, leave it
// wired so tool calls can surface the status.
@@ -29,6 +35,8 @@ 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;
configGroups = config.groups ?? [];
await Promise.allSettled(config.meshes.map(ensureClient));
}

38
apps/runner/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# claudemesh runner — executes deployed MCP servers as child processes.
# Multi-runtime: Node 22 + Python 3.12 + uv + Bun
#
# The runner supervisor (Node) listens on HTTP :7901 for commands from
# the broker (load, call, unload, health, list_tools). Each deployed
# MCP server runs as a child process with its own stdio pipe.
FROM node:22-slim AS base
# Install Python 3.12 + uv (fast pip replacement) + git
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
curl ca-certificates git unzip \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -sf /root/.local/bin/uv /usr/local/bin/uv \
&& ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \
&& curl -fsSL https://bun.sh/install | bash \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
&& ln -sf /root/.bun/bin/bunx /usr/local/bin/bunx \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the runner supervisor
COPY supervisor.mjs /app/supervisor.mjs
# Services directory (shared volume with broker)
RUN mkdir -p /var/claudemesh/services
ENV NODE_ENV=production
ENV RUNNER_PORT=7901
EXPOSE 7901
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD node -e "fetch('http://localhost:7901/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
CMD ["node", "supervisor.mjs"]

365
apps/runner/supervisor.mjs Normal file
View File

@@ -0,0 +1,365 @@
/**
* claudemesh runner supervisor — manages MCP server child processes.
*
* HTTP API (called by broker):
* POST /load { name, sourcePath, env, runtime } → spawn MCP, return tools
* POST /call { name, tool, args } → route tool call
* POST /unload { name } → kill process
* GET /health → { ok, services }
* GET /list { name? } → tools for a service
*
* Each MCP server is a child process with its own stdio pipe.
* The supervisor talks MCP JSON-RPC over stdin/stdout to each child.
*/
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const PORT = parseInt(process.env.RUNNER_PORT || "7901", 10);
const CALL_TIMEOUT_MS = 25_000;
const LOG_BUFFER_SIZE = 500;
// --- Service registry ---
const services = new Map();
let callIdCounter = 0;
// --- Runtime detection ---
function detectRuntime(sourcePath) {
if (existsSync(join(sourcePath, "bun.lockb")) || existsSync(join(sourcePath, "bunfig.toml"))) return "bun";
if (existsSync(join(sourcePath, "package.json"))) return "node";
if (existsSync(join(sourcePath, "pyproject.toml")) || existsSync(join(sourcePath, "requirements.txt"))) return "python";
return "node";
}
function detectEntry(sourcePath, runtime) {
if (runtime === "python") {
for (const e of ["server.py", "src/server.py", "main.py", "src/main.py"]) {
if (existsSync(join(sourcePath, e))) return { cmd: "python3", args: [e] };
}
if (existsSync(join(sourcePath, "pyproject.toml"))) return { cmd: "python3", args: ["-m", "server"] };
return { cmd: "python3", args: ["server.py"] };
}
const cmd = runtime === "bun" ? "bun" : "node";
if (existsSync(join(sourcePath, "package.json"))) {
try {
const pkg = JSON.parse(readFileSync(join(sourcePath, "package.json"), "utf-8"));
if (pkg.main) return { cmd, args: [pkg.main] };
if (pkg.bin) {
const bin = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
if (bin) return { cmd, args: [bin] };
}
} catch {}
}
for (const e of ["dist/index.js", "src/index.js", "src/index.ts", "index.js"]) {
if (existsSync(join(sourcePath, e))) return { cmd, args: [e] };
}
return { cmd, args: ["src/index.js"] };
}
// --- Install deps ---
function installDeps(sourcePath, runtime) {
return new Promise((resolve, reject) => {
let cmd, args;
if (runtime === "python") {
if (existsSync(join(sourcePath, "requirements.txt"))) {
cmd = "pip3"; args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
} else { cmd = "pip3"; args = ["install", "--no-cache-dir", "."]; }
} else if (runtime === "bun") {
cmd = "bun"; args = ["install"];
} else {
cmd = "npm"; args = ["install", "--production", "--legacy-peer-deps"];
}
const child = spawn(cmd, args, { cwd: sourcePath, stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr?.on("data", d => { stderr += d.toString(); });
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`${cmd} install exit ${code}: ${stderr.slice(-300)}`)));
child.on("error", reject);
});
}
// --- MCP JSON-RPC ---
function sendMcpRequest(svc, method, params) {
return new Promise(resolve => {
if (!svc.process?.stdin?.writable) { resolve({ error: "not running" }); return; }
const id = `c_${++callIdCounter}`;
const timer = setTimeout(() => { svc.pending.delete(id); resolve({ error: "timeout" }); }, CALL_TIMEOUT_MS);
svc.pending.set(id, { resolve, timer });
try {
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }) + "\n");
} catch (e) {
clearTimeout(timer); svc.pending.delete(id);
resolve({ error: e.message });
}
});
}
async function initMcp(svc) {
const init = await sendMcpRequest(svc, "initialize", {
protocolVersion: "2024-11-05", capabilities: {},
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
});
if (init.error) throw new Error(`init failed: ${init.error}`);
if (svc.process?.stdin?.writable) {
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
}
const tools = await sendMcpRequest(svc, "tools/list", {});
if (tools.error) throw new Error(`tools/list failed: ${tools.error}`);
return tools.result?.tools ?? [];
}
// --- Spawn ---
function spawnService(svc) {
// npx/uvx packages have pre-resolved entry points
let cmd, args;
if (svc._pythonModule) {
// Python MCPs: run via venv python -m <module>
cmd = svc._venvPython;
args = ["-m", svc._pythonModule];
} else if (svc._npxBin) {
cmd = "node";
args = [svc._npxBin];
} else {
({ cmd, args } = detectEntry(svc.sourcePath, svc.runtime));
}
const child = spawn(cmd, args, {
cwd: svc.sourcePath,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...svc.env, NODE_ENV: "production" },
});
svc.process = child;
svc.pid = child.pid;
svc.status = "running";
svc.healthFailures = 0;
const rl = createInterface({ input: child.stdout });
rl.on("line", line => {
try {
const msg = JSON.parse(line);
if (msg.id && svc.pending.has(String(msg.id))) {
const p = svc.pending.get(String(msg.id));
clearTimeout(p.timer); svc.pending.delete(String(msg.id));
p.resolve(msg.error ? { error: msg.error.message ?? JSON.stringify(msg.error) } : { result: msg.result });
}
} catch { svc.logs.push(`[stdout] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); }
});
const errRl = createInterface({ input: child.stderr });
errRl.on("line", line => { svc.logs.push(`[stderr] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); });
child.on("exit", (code, signal) => {
console.log(`[runner] ${svc.name} exited code=${code} signal=${signal} restarts=${svc.restarts}`);
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "crashed" }); }
svc.pending.clear(); svc.process = null; svc.pid = null;
if (svc.status === "running" && svc.restarts < 5) {
svc.restarts++;
svc.status = "restarting";
setTimeout(() => spawnService(svc), 1000 * svc.restarts);
} else if (svc.status === "running") { svc.status = "crashed"; }
});
child.on("error", err => { console.error(`[runner] ${svc.name} spawn error: ${err.message}`); svc.status = "failed"; });
console.log(`[runner] spawned ${svc.name} pid=${child.pid} cmd=${cmd} ${args.join(" ")}`);
}
// --- HTTP API ---
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", c => chunks.push(c));
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } });
req.on("error", reject);
});
}
function json(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
const server = createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/health") {
const svcs = [];
for (const [name, svc] of services) {
svcs.push({ name, status: svc.status, pid: svc.pid, tools: svc.tools.length, restarts: svc.restarts });
}
return json(res, 200, { ok: true, services: svcs });
}
if (req.method === "GET" && req.url?.startsWith("/list")) {
const url = new URL(req.url, "http://localhost");
const name = url.searchParams.get("name");
if (name) {
const svc = services.get(name);
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
return json(res, 200, { tools: svc.tools });
}
const all = {};
for (const [n, s] of services) all[n] = s.tools;
return json(res, 200, all);
}
if (req.method === "GET" && req.url?.startsWith("/logs")) {
const url = new URL(req.url, "http://localhost");
const name = url.searchParams.get("name");
const lines = parseInt(url.searchParams.get("lines") || "50", 10);
const svc = services.get(name);
if (!svc) return json(res, 404, { error: "not found" });
return json(res, 200, { lines: svc.logs.slice(-lines) });
}
if (req.method === "POST" && req.url === "/load") {
const body = await readBody(req);
const { name, sourcePath, gitUrl, gitBranch, npxPackage, env: svcEnv, runtime: rt } = body;
if (!name) return json(res, 400, { error: "name required" });
// Kill existing
const existing = services.get(name);
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
// Determine source path — git clone, npx, or pre-existing path
let svcSourcePath = sourcePath;
let svcRuntime = rt;
if (gitUrl) {
// Git clone into runner's local storage
svcSourcePath = join("/var/claudemesh/services", name);
const { execSync } = await import("node:child_process");
mkdirSync(svcSourcePath, { recursive: true });
try {
// Clean existing clone
execSync(`rm -rf ${svcSourcePath}/*`, { timeout: 10_000 });
execSync(`git clone --depth 1 ${gitBranch ? `--branch ${gitBranch}` : ""} ${gitUrl} .`, { cwd: svcSourcePath, timeout: 120_000, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
console.log(`[runner] git clone complete: ${gitUrl} -> ${svcSourcePath}`);
} catch (e) {
return json(res, 500, { error: `git clone failed: ${e.message}` });
}
} else if (npxPackage) {
// npx-based: create a minimal package.json that depends on the package
svcSourcePath = join("/var/claudemesh/services", name);
mkdirSync(svcSourcePath, { recursive: true });
const pkg = { name: `mcp-${name}`, private: true, dependencies: { [npxPackage]: "*" } };
writeFileSync(join(svcSourcePath, "package.json"), JSON.stringify(pkg, null, 2));
svcRuntime = svcRuntime || "node";
} else if (body.uvxPackage) {
// uvx-based Python MCP: install via uv and find the entry point
svcSourcePath = join("/var/claudemesh/services", name);
mkdirSync(svcSourcePath, { recursive: true });
const { execSync } = await import("node:child_process");
try {
execSync(`uv venv --clear ${join(svcSourcePath, ".venv")}`, { timeout: 30_000, stdio: "pipe" });
execSync(`uv pip install --python ${join(svcSourcePath, ".venv/bin/python")} "${body.uvxPackage}" "mcp[cli]"`, { timeout: 120_000, stdio: "pipe" });
console.log(`[runner] uvx package installed: ${body.uvxPackage}`);
} catch (e) {
return json(res, 500, { error: `uvx install failed: ${e.message}` });
}
// For Python MCPs: run via `python -m <module>` using the venv python.
// The module name is derived from the package name: mcp-server-time → mcp_server_time
const venvPython = join(svcSourcePath, ".venv/bin/python");
const moduleName = body.uvxPackage.replace(/-/g, "_");
svcRuntime = "python";
// _pythonModule signals spawnService to use `python -m <module>` instead of binary
const svc2 = { name, sourcePath: svcSourcePath, runtime: svcRuntime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "running", pending: new Map(), logs: [], restarts: 0, healthFailures: 0, _venvPython: venvPython, _pythonModule: moduleName };
services.set(name, svc2);
spawnService(svc2);
// Python MCPs take longer to start — retry init with backoff
let initErr = null;
for (let attempt = 0; attempt < 3; attempt++) {
await new Promise(r => setTimeout(r, 1500 + attempt * 1000));
try {
svc2.tools = await initMcp(svc2);
console.log(`[runner] ${name} ready (uvx), ${svc2.tools.length} tools`);
return json(res, 200, { status: "running", tools: svc2.tools });
} catch (e) { initErr = e; }
}
svc2.status = "failed"; svc2.logs.push(`MCP init failed after 3 attempts: ${initErr?.message}`);
return json(res, 500, { error: initErr?.message, logs: svc2.logs.slice(-10) });
} else if (!svcSourcePath) {
return json(res, 400, { error: "one of sourcePath, gitUrl, or npxPackage required" });
}
const runtime = svcRuntime || detectRuntime(svcSourcePath);
const svc = { name, sourcePath: svcSourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
services.set(name, svc);
// Install deps
try { await installDeps(svcSourcePath, runtime); } catch (e) {
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
return json(res, 500, { error: e.message });
}
// For npx packages: find the binary in node_modules/.bin
if (npxPackage) {
const binDir = join(svcSourcePath, "node_modules", ".bin");
if (existsSync(binDir)) {
const bins = readdirSync(binDir).filter(b => !["node-which", "which", "semver", "resolve"].includes(b));
// Prefer binary matching the package name
const pkgShort = npxPackage.split("/").pop().replace(/^@/, "");
const match = bins.find(b => b === pkgShort || b.includes(pkgShort)) || bins[0];
if (match) {
svc._npxBin = join(binDir, match);
console.log(`[runner] npx binary resolved: ${match}`);
}
}
}
// Spawn + MCP handshake
spawnService(svc);
await new Promise(r => setTimeout(r, 1000)); // npx packages may need more startup time
try {
svc.tools = await initMcp(svc);
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
return json(res, 200, { status: "running", tools: svc.tools });
} catch (e) {
svc.status = "failed"; svc.logs.push(`MCP init failed: ${e.message}`);
return json(res, 500, { error: e.message, logs: svc.logs.slice(-10) });
}
}
if (req.method === "POST" && req.url === "/call") {
const body = await readBody(req);
const { name, tool, args } = body;
const svc = services.get(name);
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
if (svc.status !== "running") return json(res, 503, { error: `service is ${svc.status}` });
const result = await sendMcpRequest(svc, "tools/call", { name: tool, arguments: args || {} });
return json(res, 200, result);
}
if (req.method === "POST" && req.url === "/unload") {
const body = await readBody(req);
const { name } = body;
const svc = services.get(name);
if (!svc) return json(res, 404, { error: "not found" });
svc.status = "stopped";
if (svc.process) { svc.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 2000)); if (svc.process) svc.process.kill("SIGKILL"); }
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "unloaded" }); }
services.delete(name);
return json(res, 200, { ok: true });
}
json(res, 404, { error: "not found" });
} catch (e) {
console.error("[runner] request error:", e);
json(res, 500, { error: e.message });
}
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[runner] supervisor listening on :${PORT}`);
});
process.on("SIGTERM", () => {
console.log("[runner] shutting down...");
for (const [, svc] of services) { svc.status = "stopped"; svc.process?.kill("SIGTERM"); }
server.close(() => process.exit(0));
});

15
apps/telegram/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# Telegram bridge for claudemesh
# Node 22 runtime with tsx for TypeScript execution
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src/ ./src/
ENV NODE_ENV=production
CMD ["npx", "tsx", "src/index.ts"]

View File

@@ -0,0 +1,20 @@
{
"name": "@claudemesh/telegram",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "bun src/index.ts",
"dev": "bun --hot src/index.ts"
},
"dependencies": {
"grammy": "^1.35.0",
"ws": "^8.18.0",
"libsodium": "^0.7.15",
"libsodium-wrappers": "^0.7.15",
"tsx": "^4.19.0"
},
"devDependencies": {
"@types/ws": "^8.5.13",
"@types/libsodium-wrappers": "^0.7.14"
}
}

759
apps/telegram/src/index.ts Normal file
View File

@@ -0,0 +1,759 @@
/**
* Claudemesh ↔ Telegram Bridge
*
* Joins the mesh as a peer named "telegram-bridge", relays messages
* between a Telegram chat and mesh peers.
*
* Telegram → Mesh:
* "@Mou fix the bug" → send_message(to: "Mou", message: "fix the bug")
* "/peers" → list_peers → reply with online list
* "/broadcast hello" → send_message(to: "*", message: "hello")
* "any text" → send_message(to: "*", message: text) (broadcast)
*
* Mesh → Telegram:
* Any push message addressed to this peer → forward to Telegram chat
*/
import { Bot, InputFile } from "grammy";
import WebSocket from "ws";
import sodium from "libsodium-wrappers";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
// --- Config ---
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const ALLOWED_CHAT_IDS = (process.env.TELEGRAM_CHAT_IDS ?? "").split(",").filter(Boolean).map(Number);
const CONFIG_DIR = process.env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const DISPLAY_NAME = process.env.BRIDGE_NAME ?? "telegram-bridge";
if (!BOT_TOKEN) {
console.error("TELEGRAM_BOT_TOKEN required");
process.exit(1);
}
// --- Load mesh config ---
interface JoinedMesh {
meshId: string;
memberId: string;
slug: string;
name: string;
pubkey: string;
secretKey: string;
brokerUrl: string;
}
function loadMeshConfig(): JoinedMesh[] {
// Support env-based config for Docker/VPS deployment
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
return [{
meshId: process.env.MESH_ID,
memberId: process.env.MESH_MEMBER_ID,
slug: process.env.MESH_SLUG ?? "mesh",
name: process.env.MESH_NAME ?? "mesh",
pubkey: process.env.MESH_PUBKEY,
secretKey: process.env.MESH_SECRET_KEY,
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
}];
}
// Fall back to config file
const path = join(CONFIG_DIR, "config.json");
if (!existsSync(path)) {
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
process.exit(1);
}
const config = JSON.parse(readFileSync(path, "utf-8"));
return config.meshes ?? [];
}
// --- Crypto ---
let sodiumReady = false;
async function ensureSodium() {
if (!sodiumReady) {
await sodium.ready;
sodiumReady = true;
}
return sodium;
}
async function generateSessionKeypair() {
const s = await ensureSodium();
const kp = s.crypto_sign_keypair();
return {
publicKey: s.to_hex(kp.publicKey),
secretKey: s.to_hex(kp.privateKey),
};
}
async function signHello(meshId: string, memberId: string, pubkey: string, secretKeyHex: string) {
const s = await ensureSodium();
const timestamp = Date.now();
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
const sig = s.crypto_sign_detached(s.from_string(canonical), s.from_hex(secretKeyHex));
return { timestamp, signature: s.to_hex(sig) };
}
/** Decrypt a direct message envelope using crypto_box (X25519). */
async function decryptDirect(
nonce: string,
ciphertext: string,
senderPubkeyHex: string,
recipientSecretKeyHex: string,
): Promise<string | null> {
const s = await ensureSodium();
try {
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(senderPubkeyHex));
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(recipientSecretKeyHex));
const nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
const ciphertextBytes = s.from_base64(ciphertext, s.base64_variants.ORIGINAL);
const plain = s.crypto_box_open_easy(ciphertextBytes, nonceBytes, senderPub, recipientSec);
return s.to_string(plain);
} catch {
return null;
}
}
// --- Mesh WS Client (simplified) ---
interface PeerInfo {
displayName: string;
pubkey: string;
status: string;
summary?: string;
cwd?: string;
groups?: string[];
avatar?: string;
}
class MeshBridge {
private ws: WebSocket | null = null;
private mesh: JoinedMesh;
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private connected = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectAttempt = 0;
private onMessage: (from: string, text: string, priority: string) => void;
private resolvers = new Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>();
/** Map pubkey → {name, avatar}, populated from list_peers */
private peerInfo = new Map<string, { name: string; avatar?: string }>();
constructor(mesh: JoinedMesh, onMessage: (from: string, text: string, priority: string) => void) {
this.mesh = mesh;
this.onMessage = onMessage;
}
async connect(): Promise<void> {
const sessionKP = await generateSessionKeypair();
this.sessionPubkey = sessionKP.publicKey;
this.sessionSecretKey = sessionKP.secretKey;
return this._connect();
}
private _connect(): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.mesh.brokerUrl);
this.ws = ws;
ws.on("open", async () => {
try {
const { timestamp, signature } = await signHello(
this.mesh.meshId, this.mesh.memberId,
this.mesh.pubkey, this.mesh.secretKey,
);
ws.send(JSON.stringify({
type: "hello",
meshId: this.mesh.meshId,
memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey,
sessionPubkey: this.sessionPubkey,
displayName: DISPLAY_NAME,
sessionId: `telegram-${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
hostname: require("os").hostname(),
peerType: "bridge",
channel: "telegram",
timestamp,
signature,
}));
} catch (e) {
reject(e);
}
});
const helloTimeout = setTimeout(() => {
ws.close();
reject(new Error("hello_ack timeout"));
}, 10_000);
ws.on("message", async (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type !== "hello_ack" && msg.type !== "ack") {
console.log(`[mesh] recv: ${msg.type}${msg.subtype ? '/' + msg.subtype : ''}${msg.event ? '/' + msg.event : ''}`);
}
if (msg.type === "hello_ack") {
clearTimeout(helloTimeout);
this.connected = true;
this.reconnectAttempt = 0;
console.log(`[mesh] connected to ${this.mesh.slug} as ${DISPLAY_NAME}`);
resolve();
return;
}
// Push messages from peers
if (msg.type === "push") {
let text: string | null = null;
const senderPubkey = msg.senderPubkey ?? msg.senderSessionPubkey;
// System messages (no encryption)
if (msg.subtype === "system") {
const event = msg.event ?? "";
const data = msg.eventData ?? {};
if (event === "peer_joined") text = `[joined] ${data.displayName ?? "peer"}`;
else if (event === "peer_left") text = `[left] ${data.displayName ?? "peer"}`;
else if (event === "peer_returned") text = `[returned] ${data.name ?? "peer"}`;
else text = msg.plaintext ?? `[${event}]`;
}
// Encrypted direct message
else if (senderPubkey && msg.nonce && msg.ciphertext) {
// Try session key first, then mesh member key
text = await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.sessionSecretKey!)
?? await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.mesh.secretKey);
if (!text) text = "[could not decrypt]";
}
// Plaintext fallback (broadcasts, legacy)
else if (msg.plaintext) {
text = msg.plaintext;
}
// Base64 ciphertext without nonce (legacy broadcast)
else if (msg.ciphertext && !msg.nonce) {
try { text = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); } catch { text = "[decode error]"; }
}
if (text) {
const info = senderPubkey ? this.peerInfo.get(senderPubkey) : null;
const fromName = info?.name ?? (senderPubkey?.slice(0, 12) ?? "system");
const avatar = info?.avatar ?? "🤖";
console.log(`[mesh] push from ${avatar} ${fromName}: ${text.slice(0, 80)}`);
this.onMessage(`${avatar} ${fromName}`, text, msg.priority ?? "next");
} else {
console.log(`[mesh] push with no text. subtype=${msg.subtype}, hasSender=${!!senderPubkey}, hasNonce=${!!msg.nonce}, hasCipher=${!!msg.ciphertext}, hasPlain=${!!msg.plaintext}`);
}
}
// Resolve pending requests
const reqId = msg._reqId;
if (reqId && this.resolvers.has(reqId)) {
const r = this.resolvers.get(reqId)!;
clearTimeout(r.timer);
this.resolvers.delete(reqId);
r.resolve(msg);
}
} catch { /* ignore parse errors */ }
});
ws.on("close", () => {
this.connected = false;
this.ws = null;
if (this.reconnectTimer) return;
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
this.reconnectAttempt++;
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._connect().catch(e => console.error("[mesh] reconnect failed:", e));
}, delay);
});
ws.on("error", (err) => {
console.error("[mesh] ws error:", err.message);
});
});
}
private makeReqId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
private request(msg: Record<string, unknown>, timeout = 10_000): Promise<any> {
return new Promise((resolve) => {
const reqId = this.makeReqId();
const timer = setTimeout(() => {
this.resolvers.delete(reqId);
resolve(null);
}, timeout);
this.resolvers.set(reqId, { resolve, timer });
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
});
}
async sendMessage(to: string, message: string, priority: string = "next"): Promise<boolean> {
if (!this.ws || !this.connected) return false;
// For direct targets (pubkeys), use crypto_box encryption.
// For broadcasts/groups, use base64-encoded plaintext (legacy format).
let nonce = "";
let ciphertext = "";
const isDirect = /^[0-9a-f]{64}$/.test(to);
if (isDirect) {
const s = await ensureSodium();
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(to));
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(this.sessionSecretKey!));
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
const ciphertextBytes = s.crypto_box_easy(s.from_string(message), nonceBytes, recipientPub, senderSec);
nonce = s.to_base64(nonceBytes, s.base64_variants.ORIGINAL);
ciphertext = s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL);
} else {
// Broadcast/group: base64 plaintext (CLI decodes this when no nonce present)
ciphertext = Buffer.from(message, "utf-8").toString("base64");
}
const id = this.makeReqId();
console.log(`[mesh] sending to ${to.slice(0, 16)}…, encrypted=${isDirect}`);
this.ws.send(JSON.stringify({
type: "send",
id,
targetSpec: to,
priority,
nonce,
ciphertext,
}));
return true;
}
/** Find all peers matching a display name. */
async findPeersByName(name: string): Promise<PeerInfo[]> {
const peers = await this.listPeers();
return peers.filter(p => p.displayName.toLowerCase() === name.toLowerCase());
}
/** Upload a file to the mesh via broker HTTP. Returns file ID. */
async uploadFile(data: Buffer, fileName: string, tags?: string[]): Promise<string | null> {
const brokerHttp = this.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
try {
const res = await fetch(`${brokerHttp}/upload`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"X-Mesh-Id": this.mesh.meshId,
"X-Member-Id": this.mesh.memberId,
"X-File-Name": fileName,
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
"X-Persistent": "true",
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
if (!res.ok || !body.fileId) return null;
return body.fileId;
} catch (e) {
console.error("[mesh] upload failed:", e);
return null;
}
}
/** Get a download URL for a mesh file. */
async getFileUrl(fileId: string): Promise<{ url: string; name: string } | null> {
const resp = await this.request({ type: "get_file", fileId });
if (!resp?.url) return null;
return { url: resp.url, name: resp.name ?? "file" };
}
async listPeers(): Promise<PeerInfo[]> {
const resp = await this.request({ type: "list_peers" });
if (!resp?.peers) return [];
return resp.peers.map((p: any) => {
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
const avatar = p.profile?.avatar;
// Cache pubkey → info for push message attribution
const info = { name, avatar };
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
return {
displayName: name,
pubkey: p.pubkey ?? "",
status: p.status ?? "unknown",
summary: p.summary,
cwd: p.cwd,
groups: p.groups?.map((g: any) => g.name) ?? [],
avatar: avatar,
};
});
}
/** Refresh peer name cache. Called periodically. */
async refreshPeerNames(): Promise<void> {
await this.listPeers();
}
async setSummary(summary: string): Promise<void> {
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
}
isConnected(): boolean {
return this.connected;
}
close(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
}
}
// --- Resolve display name from peers ---
async function resolveTarget(bridge: MeshBridge, name: string): Promise<string> {
// If it starts with @, it's a group
if (name.startsWith("@")) return name;
// If *, broadcast
if (name === "*") return "*";
// Otherwise resolve as display name — the broker handles this via targetSpec
return name;
}
// --- Telegram Bot ---
async function main() {
const meshes = loadMeshConfig();
if (meshes.length === 0) {
console.error("No meshes joined — run 'claudemesh join' first");
process.exit(1);
}
const bot = new Bot(BOT_TOKEN);
const bridges: MeshBridge[] = [];
// One bridge per mesh
for (const mesh of meshes) {
const bridge = new MeshBridge(mesh, (from, text, priority) => {
// Forward mesh messages to all allowed Telegram chats
const prefix = `[${mesh.slug}] ${from}`;
const formatted = `💬 *${prefix}*\n${text}`;
for (const chatId of ALLOWED_CHAT_IDS) {
bot.api.sendMessage(chatId, formatted, { parse_mode: "Markdown" }).catch(e => {
console.error(`[tg] failed to send to ${chatId}:`, e.message);
});
}
});
try {
await bridge.connect();
await bridge.setSummary("Telegram bridge — relays messages between Telegram and mesh peers");
await bridge.refreshPeerNames();
bridges.push(bridge);
// Refresh peer names every 30s for display name resolution on pushes
setInterval(() => bridge.refreshPeerNames().catch(() => {}), 30_000);
} catch (e) {
console.error(`[mesh] failed to connect to ${mesh.slug}:`, e);
}
}
if (bridges.length === 0) {
console.error("Failed to connect to any mesh");
process.exit(1);
}
const defaultBridge = bridges[0]!;
// --- Bot commands ---
bot.command("peers", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const peers = await defaultBridge.listPeers();
if (peers.length === 0) {
await ctx.reply("No peers online.");
return;
}
const lines = peers.map(p => {
const status = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
const summary = p.summary ? ` — _${p.summary}_` : "";
return `${status} *${p.displayName}*${summary}`;
});
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
});
// Pending messages waiting for peer selection (chatId → {message, matches})
const pendingDMs = new Map<number, { message: string; matches: PeerInfo[]; selected: Set<number> }>();
bot.command("dm", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const text = ctx.match;
if (!text) {
await ctx.reply("Usage: /dm <peer-name> <message>");
return;
}
const spaceIdx = text.indexOf(" ");
if (spaceIdx === -1) {
await ctx.reply("Usage: /dm <peer-name> <message>");
return;
}
const target = text.slice(0, spaceIdx);
const message = text.slice(spaceIdx + 1);
// Find matching peers
const matches = await defaultBridge.findPeersByName(target);
if (matches.length === 0) {
await ctx.reply(`❌ No peer named "${target}" found.`);
return;
}
if (matches.length === 1) {
// Single match — send directly
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
return;
}
// Multiple matches — show picker with individual + all option
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
const buttons = matches.map((p, i) => {
const dir = p.cwd?.split("/").pop() ?? "?";
const avatar = p.avatar ?? "🤖";
return [{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
});
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
reply_markup: { inline_keyboard: buttons },
});
});
bot.command("broadcast", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const message = ctx.match;
if (!message) {
await ctx.reply("Usage: /broadcast <message>");
return;
}
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${message}`, "now");
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
});
bot.command("group", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const text = ctx.match;
if (!text) {
await ctx.reply("Usage: /group <@group-name> <message>");
return;
}
const spaceIdx = text.indexOf(" ");
if (spaceIdx === -1) {
await ctx.reply("Usage: /group <@group-name> <message>");
return;
}
const target = text.slice(0, spaceIdx);
const message = text.slice(spaceIdx + 1);
const ok = await defaultBridge.sendMessage(target, `[via Telegram] ${message}`, "now");
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
});
bot.command("status", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const meshStatus = bridges.map(b =>
`${b.isConnected() ? "🟢" : "🔴"} Connected`
).join("\n");
await ctx.reply(`*Claudemesh Telegram Bridge*\n${meshStatus}`, { parse_mode: "Markdown" });
});
// --- File: get a mesh file by ID ---
bot.command("file", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const fileId = ctx.match?.trim();
if (!fileId) {
await ctx.reply("Usage: /file <file-id>");
return;
}
const file = await defaultBridge.getFileUrl(fileId);
if (!file) {
await ctx.reply(`❌ File ${fileId} not found`);
return;
}
try {
const resp = await fetch(file.url, { signal: AbortSignal.timeout(30_000) });
if (!resp.ok) { await ctx.reply(`❌ Download failed (${resp.status})`); return; }
const buf = Buffer.from(await resp.arrayBuffer());
await ctx.replyWithDocument(new InputFile(buf, file.name));
} catch (e) {
await ctx.reply(`${e instanceof Error ? e.message : String(e)}`);
}
});
bot.command("start", async (ctx) => {
if (!isAllowed(ctx.chat.id)) {
await ctx.reply("⛔ Not authorized. Add your chat ID to TELEGRAM_CHAT_IDS.");
return;
}
await ctx.reply(
"🔗 *Claudemesh Telegram Bridge*\n\n" +
"Commands:\n" +
"• /peers — List online peers\n" +
"• /dm <name> <msg> — DM a specific peer\n" +
"• /broadcast <msg> — Message all peers\n" +
"• /group @name <msg> — Message a group\n" +
"• /file <id> — Download a mesh file\n" +
"• /status — Bridge connection status\n\n" +
"Send a photo/document to share it with the mesh.\n" +
"Or just type a message to broadcast it.",
{ parse_mode: "Markdown" },
);
});
// Handle inline keyboard callbacks for peer selection
bot.on("callback_query:data", async (ctx) => {
const data = ctx.callbackQuery.data;
const chatId = ctx.chat?.id;
if (!chatId || !data.startsWith("dm:")) {
await ctx.answerCallbackQuery();
return;
}
const pending = pendingDMs.get(chatId);
if (!pending) {
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
return;
}
if (data === "dm:all") {
// Send to all matches
let sent = 0;
for (const p of pending.matches) {
const ok = await defaultBridge.sendMessage(p.pubkey, `[via Telegram] ${pending.message}`, "now");
if (ok) sent++;
}
pendingDMs.delete(chatId);
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
await ctx.editMessageText(`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`);
return;
}
// Single selection: dm:0, dm:1, etc.
const idx = parseInt(data.slice(3));
const peer = pending.matches[idx];
if (!peer) {
await ctx.answerCallbackQuery({ text: "Invalid selection" });
return;
}
const ok = await defaultBridge.sendMessage(peer.pubkey, `[via Telegram] ${pending.message}`, "now");
pendingDMs.delete(chatId);
const dir = peer.cwd?.split("/").pop() ?? "?";
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
await ctx.editMessageText(ok ? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})` : "❌ Not connected");
});
// Handle photos from Telegram → share to mesh
bot.on("message:photo", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const photo = ctx.message.photo.at(-1); // highest resolution
if (!photo) return;
try {
const file = await ctx.api.getFile(photo.file_id);
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
const resp = await fetch(url);
const buf = Buffer.from(await resp.arrayBuffer());
const name = `telegram-photo-${Date.now()}.jpg`;
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "photo"]);
if (fileId) {
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
await defaultBridge.sendMessage("*", `[via Telegram] 📷 Photo shared${caption} (file: ${fileId})`, "next");
await ctx.reply(`✅ Photo shared to mesh (${fileId})`);
} else {
await ctx.reply("❌ Upload failed");
}
} catch (e) {
await ctx.reply(`${e instanceof Error ? e.message : String(e)}`);
}
});
// Handle documents from Telegram → share to mesh
bot.on("message:document", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const doc = ctx.message.document;
if (!doc) return;
try {
const file = await ctx.api.getFile(doc.file_id);
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
const resp = await fetch(url);
const buf = Buffer.from(await resp.arrayBuffer());
const name = doc.file_name ?? `telegram-file-${Date.now()}`;
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "document"]);
if (fileId) {
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
await defaultBridge.sendMessage("*", `[via Telegram] 📎 File shared: ${name}${caption} (file: ${fileId})`, "next");
await ctx.reply(`✅ File shared to mesh: ${name} (${fileId})`);
} else {
await ctx.reply("❌ Upload failed");
}
} catch (e) {
await ctx.reply(`${e instanceof Error ? e.message : String(e)}`);
}
});
// Default: any text without a command → broadcast
bot.on("message:text", async (ctx) => {
if (!isAllowed(ctx.chat.id)) return;
const text = ctx.message.text;
if (text.startsWith("/")) return; // Skip unknown commands
// Check for @mention pattern: "@PeerName message"
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
if (mentionMatch) {
const target = mentionMatch[1]!;
const message = mentionMatch[2]!;
const matches = await defaultBridge.findPeersByName(target);
if (matches.length === 0) {
await ctx.reply(`❌ No peer named "${target}"`);
} else if (matches.length === 1) {
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
} else {
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
const buttons = matches.map((p, i) => {
const dir = p.cwd?.split("/").pop() ?? "?";
return [{ text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
});
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
await ctx.reply(`Multiple "${target}" peers. Pick one or all:`, {
reply_markup: { inline_keyboard: buttons },
});
}
return;
}
// No mention → broadcast
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${text}`, "next");
if (!ok) await ctx.reply("❌ Not connected to mesh");
});
function isAllowed(chatId: number): boolean {
// If no chat IDs configured, allow all (dev mode)
if (ALLOWED_CHAT_IDS.length === 0) return true;
return ALLOWED_CHAT_IDS.includes(chatId);
}
// Start bot
console.log("[tg] starting bot...");
bot.start({
onStart: () => console.log("[tg] bot running"),
});
// Graceful shutdown
process.on("SIGINT", () => {
console.log("[shutdown] closing...");
bot.stop();
bridges.forEach(b => b.close());
process.exit(0);
});
process.on("SIGTERM", () => {
console.log("[shutdown] closing...");
bot.stop();
bridges.forEach(b => b.close());
process.exit(0);
});
}
main().catch(e => {
console.error("fatal:", e);
process.exit(1);
});

View File

@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
MISTRAL_API_KEY="<your-mistral-api-key>"
# Perplexity API key - required only if you use Perplexity as an AI provider
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
##############################
### CLI Sync config ###
##############################
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
CLI_SYNC_SECRET="<your-cli-sync-secret>"

View File

@@ -10,7 +10,11 @@ RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
# pnpm workspace needs full context to resolve workspace:* + catalog:
COPY . .
RUN pnpm install --frozen-lockfile
# --ignore-scripts skips sherif postinstall linting (exits 1 on warnings)
RUN pnpm install --frozen-lockfile --ignore-scripts && \
node node_modules/esbuild/install.js && \
node node_modules/sharp/install/check.js || npm run --prefix node_modules/sharp build 2>/dev/null; \
true
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
ENV NODE_ENV=production
@@ -25,6 +29,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# Node ESM loader that stubs .css imports during route collection.
# Payload CMS deps import .css files that Node can't handle outside webpack.
ENV NODE_OPTIONS="--import /app/apps/web/css-stub-loader.mjs"
RUN npx turbo run build --filter=web...
# Stage 2: runtime — standalone output only

View File

@@ -0,0 +1,33 @@
/**
* Node.js ESM custom loader — stubs static asset imports as empty modules.
*
* Next.js 16 does route collection in raw Node ESM (not webpack/turbopack).
* Payload CMS deps import .css, .scss, .svg, and other assets that Node
* can't handle. This loader intercepts those and returns empty modules.
*
* Usage: NODE_OPTIONS="--import ./apps/web/css-stub-loader.mjs"
*/
import { register } from "node:module";
register(
"data:text/javascript," +
encodeURIComponent(`
const STYLE_RE = /\\.(css|scss|sass|less|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|otf)$/;
export function resolve(specifier, context, nextResolve) {
if (STYLE_RE.test(specifier)) {
return { url: 'data:text/javascript,export default {};', shortCircuit: true };
}
return nextResolve(specifier, context);
}
export function load(url, context, nextLoad) {
if (STYLE_RE.test(url)) {
return { format: 'module', source: 'export default {};', shortCircuit: true };
}
return nextLoad(url, context);
}
`),
import.meta.url,
);

View File

@@ -1,5 +1,8 @@
import type { NextConfig } from "next";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { withPayload } = require("@payloadcms/next/withPayload");
import env from "./env.config";
const INTERNAL_PACKAGES = [
@@ -85,7 +88,11 @@ const config: NextConfig = {
"@payloadcms/db-postgres",
"@payloadcms/db-sqlite",
"@payloadcms/richtext-lexical",
"@payloadcms/next",
"@payloadcms/ui",
"react-image-crop",
"sharp",
"libsodium-wrappers",
],
turbopack: {
rules: {
@@ -96,6 +103,24 @@ const config: NextConfig = {
},
},
// Webpack SVG loader (used when TURBOPACK=0 for production builds).
// Exclude app/ dir SVGs (icon.svg, opengraph-image) — Next.js metadata
// loader handles those. Only process package SVGs (flags, logos).
webpack(config) {
const existingSvgRule = config.module.rules.find(
(rule: { test?: RegExp }) => rule.test?.test?.(".svg"),
);
if (existingSvgRule) {
existingSvgRule.exclude = /packages\/ui\/.*\.svg$/;
}
config.module.rules.push({
test: /\.svg$/,
include: /packages\/ui\//,
use: ["@svgr/webpack"],
});
return config;
},
images: {
remotePatterns: [
{
@@ -130,4 +155,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: env.ANALYZE,
});
export default withBundleAnalyzer(config);
export default withPayload(withBundleAnalyzer(config));

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"build": "next build --webpack",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,14 @@
import "@payloadcms/next/css";
import type { ReactNode } from "react";
export const metadata = {
title: "CMS — claudemesh",
};
export default function PayloadLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,16 @@
/* eslint-disable */
// @ts-nocheck — Payload generates these types at build time
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ segments: string[] }> };
export const generateMetadata = ({ params }: Args) =>
generatePageMetadata({ config, params });
export default function Page({ params }: Args) {
return <RootPage config={config} params={params} importMap={importMap} />;
}

View File

@@ -0,0 +1,51 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View File

@@ -153,14 +153,14 @@ export default function AboutPage() {
GitHub
</Link>
<Link
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
href="https://www.linkedin.com/in/alejandro-mourente/"
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"
href="mailto:alex@mourente.ai"
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)" }}
>

View File

@@ -0,0 +1,458 @@
import Link from "next/link";
import { getMetadata } from "~/lib/metadata";
export const generateMetadata = getMetadata({
title: "Getting Started",
description:
"Install the CLI and launch your first peer session in two commands.",
});
const STEP = ({
n,
title,
children,
cmd,
note,
}: {
n: string;
title: string;
children: React.ReactNode;
cmd?: string;
note?: string;
}) => (
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 md:p-8">
<div
className="mb-4 flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)]/15 text-[11px] font-medium">
{n}
</span>
{title}
</div>
<div
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{children}
</div>
{cmd && (
<pre
className="mt-4 overflow-x-auto rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-3 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{cmd}</code>
</pre>
)}
{note && (
<p
className="mt-3 text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{note}
</p>
)}
</div>
);
const VERIFY_CHECKS = [
"Node.js >= 20 installed",
"claude binary on PATH",
"~/.claudemesh/config.json parses + chmod 0600",
"Mesh keypairs valid",
"Broker connectivity",
];
export default function GettingStartedPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-16 md:px-12 md:py-24">
<div
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
getting started
</div>
<h1
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
From zero to meshed in two minutes
</h1>
<p
className="mt-4 max-w-xl text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Install the CLI and launch. Two commands join is built into launch.
</p>
{/* Prerequisites */}
<div className="mt-14 mb-10">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Prerequisites
</h2>
<ul
className="space-y-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<li className="flex items-start gap-2">
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
<strong className="text-[var(--cm-fg)]">Node.js 20+</strong> {" "}
<Link
href="https://nodejs.org"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
nodejs.org
</Link>
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
<strong className="text-[var(--cm-fg)]">Claude Code 2.0+</strong>{" "}
{" "}
<Link
href="https://claude.com/claude-code"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
claude.com/claude-code
</Link>
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>
<strong className="text-[var(--cm-fg)]">An invite link</strong>
from a mesh owner, or{" "}
<Link
href="/auth/register"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
create your own mesh
</Link>
</span>
</li>
</ul>
</div>
{/* Steps */}
<div className="space-y-6">
<STEP
n="1"
title="Install the CLI"
cmd="npm i -g claudemesh-cli"
note="Requires Node.js 20+. Installs the claudemesh CLI globally."
>
<p>
One command. If you get a permissions error, see{" "}
<Link
href="https://docs.npmjs.com/resolving-eacces-permissions-errors"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
npm docs
</Link>
.
</p>
</STEP>
<STEP
n="2"
title="Launch"
cmd='claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...'
note="--join enrolls you in the mesh (first time only). On subsequent launches, drop the --join flag."
>
<p>
This does everything: verifies the invite, generates your ed25519
keypair, enrolls with the broker, and spawns Claude Code with
real-time peer messaging. Your keys are stored in{" "}
<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/config.json
</code>{" "}
(chmod 0600) the broker never sees them.
</p>
</STEP>
<div
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
next time, just:
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
claudemesh launch --name Alice
</code>
</div>
<pre
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{`# Full example with all flags
claudemesh launch \\
--name Alice \\
--join https://claudemesh.com/join/eyJ2IjoxLC... \\
--role dev \\
--groups "frontend:lead,reviewers" \\
--message-mode push \\
-y # skip permission confirmation`}</code>
</pre>
</div>
{/* Verify */}
<div className="mt-16">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Verify your setup
</h2>
<p
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Run the diagnostic check it walks through every precondition and
prints pass/fail with fix hints:
</p>
<pre
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{`$ claudemesh doctor
claudemesh doctor (v0.8.0)
────────────────────────────────────────────────────────────
✓ Node.js >= 20 (v22.15.0)
✓ claude binary on PATH
✓ ~/.claudemesh/config.json parses + chmod 0600
✓ Mesh keypairs valid (1 mesh(es))
✓ Broker connectivity (wss://ic.claudemesh.com/ws)
All checks passed.`}</code>
</pre>
</div>
{/* Invite a teammate */}
<div className="mt-16">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Invite a teammate
</h2>
<p
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Mesh owners generate invite links from the{" "}
<Link
href="/dashboard"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
dashboard
</Link>
. Each link is a signed ed25519 token with a mesh ID, broker URL,
expiry, and role (admin or member). Share via Slack, email, or
paste in chat.
</p>
<p
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
The recipient runs{" "}
<code
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
claudemesh launch --name Name --join &lt;link&gt;
</code>{" "}
joins the mesh and launches in one step. No account creation
needed. Identity is the ed25519 keypair.
</p>
</div>
{/* Invite link formats */}
<div className="mt-10">
<h3
className="mb-3 text-base font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Accepted invite formats
</h3>
<pre
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{`# Join + launch in one step (recommended)
claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...
# Or join separately first
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
claudemesh launch --name Alice
# All invite formats work with both join and --join:
# https://claudemesh.com/join/eyJ2IjoxLC...
# https://claudemesh.com/en/join/eyJ2IjoxLC...
# ic://join/eyJ2IjoxLC...
# eyJ2IjoxLC4uLg (raw token)`}</code>
</pre>
</div>
{/* Message modes */}
<div className="mt-16">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Message modes
</h2>
<div className="grid gap-4 md:grid-cols-3">
{[
{
mode: "push",
desc: "Real-time. Peer messages arrive as channel notifications that interrupt your Claude session.",
when: "Default. Best for active collaboration.",
},
{
mode: "inbox",
desc: "Held until you check. You get a notification but messages queue until check_messages.",
when: "Deep work. Check when ready.",
},
{
mode: "off",
desc: "No delivery. Tools still work — use check_messages to poll manually.",
when: "Solo work on a shared mesh.",
},
].map((m) => (
<div
key={m.mode}
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
>
<code
className="mb-2 block text-sm font-medium text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
--message-mode {m.mode}
</code>
<p
className="mb-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{m.desc}
</p>
<p
className="text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.when}
</p>
</div>
))}
</div>
</div>
{/* With vs without launch */}
<div className="mt-16">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh launch</code> vs plain{" "}
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>
</h2>
<div className="grid 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-6">
<div
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
claudemesh launch
</div>
<ul
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<li>Real-time push messages from peers</li>
<li>Native MCP entries for deployed mesh services</li>
<li>Per-session ephemeral keypair</li>
<li>Display name, groups, and roles</li>
<li>Session config isolated in tmpdir</li>
<li>MCP_TIMEOUT + output limits tuned for mesh</li>
</ul>
</div>
<div className="bg-[var(--cm-bg-elevated)] p-6">
<div
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
plain claude
</div>
<ul
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<li>All 43 MCP tools still work</li>
<li>Messages are pull-only (check_messages)</li>
<li>No real-time push delivery</li>
<li>Uses member keypair (not ephemeral)</li>
<li>No display name or group assignment</li>
</ul>
</div>
</div>
</div>
{/* Uninstall */}
<div className="mt-16">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Uninstall
</h2>
<pre
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{`claudemesh uninstall # remove MCP server, hooks, and allowedTools
npm uninstall -g claudemesh-cli
rm -rf ~/.claudemesh # delete config + keypairs (irreversible)`}</code>
</pre>
</div>
{/* CTA */}
<div className="mt-16 flex flex-col items-start gap-4 border-t border-[var(--cm-border)] pt-10">
<p
className="text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Need help? Run{" "}
<code
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
claudemesh doctor
</code>{" "}
to diagnose issues, or{" "}
<Link
href="https://github.com/alezmad/claudemesh-cli/issues"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
open an issue on GitHub
</Link>
.
</p>
<Link
href="/auth/register"
className="inline-flex items-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)" }}
>
Create a mesh
</Link>
</div>
</div>
);
}

View File

@@ -1,20 +1,13 @@
import { Hero } from "~/modules/marketing/home/hero";
import { Surfaces } from "~/modules/marketing/home/surfaces";
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 { Timeline } from "~/modules/marketing/home/timeline";
import { Pricing } from "~/modules/marketing/home/pricing";
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 = () => {
@@ -24,14 +17,10 @@ const HomePage = () => {
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<Hero />
<Surfaces />
<Pricing />
<LaptopToLaptop />
<Features />
<MeetsYou />
<WhatIsClaudemesh />
<DemoDashboard />
<BeyondTerminal />
<Timeline />
<Pricing />
<FAQ />
<CallToAction />
<MeshStats />

View File

@@ -0,0 +1,876 @@
"use client";
import Link from "next/link";
import { motion, AnimatePresence } from "motion/react";
import { useEffect, useState, useRef } from "react";
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Mesh {
id: string;
name: string;
slug: string;
myRole: "admin" | "member";
isOwner: boolean;
memberCount: number;
}
interface Props {
code: string | null;
port: string | null;
userId: string;
userEmail: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const slugify = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
const ease = [0.22, 0.61, 0.36, 1] as const;
// ---------------------------------------------------------------------------
// Animated mesh node background
// ---------------------------------------------------------------------------
function MeshBackdrop() {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{/* Radial glow */}
<div
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
style={{
backgroundImage:
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
}}
/>
{/* Floating mesh nodes */}
{[
{ x: "12%", y: "18%", delay: 0, size: 3 },
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
].map((node, i) => (
<motion.div
key={i}
className="absolute rounded-full bg-[var(--cm-clay)]"
style={{
left: node.x,
top: node.y,
width: node.size,
height: node.size,
}}
animate={{
opacity: [0.15, 0.4, 0.15],
scale: [1, 1.5, 1],
}}
transition={{
duration: 4,
ease: "easeInOut",
repeat: Infinity,
delay: node.delay,
}}
/>
))}
{/* Connecting lines (SVG) */}
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
<line
x1="12%"
y1="18%"
x2="45%"
y2="80%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
<line
x1="85%"
y1="14%"
x2="72%"
y2="55%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
<line
x1="72%"
y1="55%"
x2="92%"
y2="78%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
<line
x1="8%"
y1="65%"
x2="45%"
y2="80%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
</svg>
</div>
);
}
// ---------------------------------------------------------------------------
// Terminal-style status indicator
// ---------------------------------------------------------------------------
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
const colors = {
waiting: "bg-[var(--cm-clay)]",
syncing: "bg-amber-400",
done: "bg-emerald-400",
error: "bg-red-400",
};
return (
<span className="relative inline-flex h-2 w-2">
<span
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
/>
<span
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
/>
</span>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
const [meshes, setMeshes] = useState<Mesh[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [redirected, setRedirected] = useState(false);
// Create-mesh form state
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [slugDirty, setSlugDirty] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
// Auto-slug from name
useEffect(() => {
if (!slugDirty && newName) {
setNewSlug(slugify(newName));
}
}, [newName, slugDirty]);
// Fetch user meshes
useEffect(() => {
(async () => {
try {
const { data } = await handle(api.my.meshes.$get, {
schema: getMyMeshesResponseSchema,
})({
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
});
setMeshes(data);
setSelected(new Set(data.map((m) => m.id)));
} catch (e) {
setError(
e instanceof Error ? e.message : "Failed to load your meshes.",
);
} finally {
setLoading(false);
}
})();
}, []);
// Auto-focus name input when no meshes
useEffect(() => {
if (!loading && meshes.length === 0 && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [loading, meshes.length]);
const toggleMesh = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const status = token
? redirected
? "done"
: "done"
: syncing || creating
? "syncing"
: error
? "error"
: "waiting";
// ---------------------------------------------------------------------------
// Create mesh
// ---------------------------------------------------------------------------
const handleCreate = async () => {
if (!newName.trim() || !newSlug.trim()) return;
setCreating(true);
setCreateError(null);
try {
const createRes = await fetch("/api/my/meshes", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: newName.trim(),
slug: newSlug.trim(),
visibility: "private",
transport: "managed",
}),
});
const res = (await createRes.json()) as
| { id: string; slug: string }
| { error: string };
if (!createRes.ok || "error" in res) {
setCreateError("error" in res ? res.error : "Failed to create mesh.");
setCreating(false);
return;
}
await doSync(
[{ id: res.id, slug: res.slug, role: "admin" as const }],
"create",
{ name: newName.trim(), slug: newSlug.trim() },
);
} catch (e) {
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
} finally {
setCreating(false);
}
};
// ---------------------------------------------------------------------------
// Sync flow
// ---------------------------------------------------------------------------
const doSync = async (
meshList: Array<{ id: string; slug: string; role: string }>,
action: "sync" | "create" = "sync",
newMesh?: { name: string; slug: string },
) => {
setSyncing(true);
setError(null);
try {
const res = await fetch("/api/cli-sync-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ meshes: meshList, action, newMesh }),
});
const data = (await res.json()) as { token?: string; error?: string };
if (!res.ok) {
setError(data.error ?? "Failed to generate token.");
setSyncing(false);
return;
}
const jwt = data.token as string;
setToken(jwt);
if (port) {
setRedirected(true);
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
} finally {
setSyncing(false);
}
};
const handleSync = () => {
const selectedMeshes = meshes
.filter((m) => selected.has(m.id))
.map((m) => ({
id: m.id,
slug: m.slug,
role: m.isOwner ? "admin" : m.myRole,
}));
if (selectedMeshes.length === 0) {
setError("Select at least one mesh to sync.");
return;
}
doSync(selectedMeshes, "sync");
};
const handleCopy = async () => {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<>
{/* Header */}
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
<div className="flex items-center justify-between">
<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>
{/* Status indicator */}
<div
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<StatusPulse status={status} />
<span>
{status === "waiting" && "awaiting sync"}
{status === "syncing" && "generating token..."}
{status === "done" && "synced"}
{status === "error" && "error"}
</span>
</div>
</div>
</header>
{/* Content */}
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
<MeshBackdrop />
{/* Section tag */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
cli sync
</motion.div>
{/* Title */}
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease, delay: 0.08 }}
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Sync with{" "}
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease, delay: 0.16 }}
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Link your terminal session to your account and choose which meshes to
sync.
</motion.p>
{/* Pairing code */}
<AnimatePresence>
{code && (
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.5, ease, delay: 0.24 }}
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
>
{/* Terminal-style header bar */}
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
<div className="flex gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
</div>
<span
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
pairing verification
</span>
</div>
{/* Code display */}
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
<div className="flex items-center gap-4">
<span
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
code:
</span>
<motion.span
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.5 }}
>
{code.split("").map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
>
{char}
</motion.span>
))}
</motion.span>
</div>
<p
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Confirm this matches the code shown in your terminal.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Loading skeleton */}
<AnimatePresence>
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="mt-10 space-y-3"
>
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</motion.div>
)}
</AnimatePresence>
{/* Error */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
>
<span className="mt-0.5 text-red-400">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</span>
<span className="text-sm text-red-400">{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* Token result */}
<AnimatePresence>
{token && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
className="mt-10"
>
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
{/* Success header */}
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-emerald-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<span
className="text-sm font-medium text-emerald-400"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
</span>
</div>
{/* Token body */}
<div className="bg-[var(--cm-bg-elevated)] p-5">
<p
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{redirected
? "If your terminal didn\u2019t pick up the token, copy it manually:"
: "Paste this token in your terminal when prompted:"}
</p>
<div className="flex items-stretch gap-2">
<div
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
onClick={(e) => {
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}}
>
{token}
</div>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={handleCopy}
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
>
{copied ? (
<span className="text-emerald-400">Copied</span>
) : (
"Copy"
)}
</motion.button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Mesh list */}
{!loading && !token && meshes.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-10"
>
<h2
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Your meshes
</h2>
<div className="space-y-2">
{meshes.map((m, i) => (
<motion.label
key={m.id}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
selected.has(m.id)
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
}`}
>
{/* Custom checkbox */}
<div
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
selected.has(m.id)
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
}`}
>
{selected.has(m.id) && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleMesh(m.id)}
className="sr-only"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-[var(--cm-fg)]">
{m.name}
</span>
<span
className="text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.slug}
</span>
</div>
<span className="text-xs text-[var(--cm-fg-tertiary)]">
{m.memberCount}{" "}
{m.memberCount === 1 ? "member" : "members"}
</span>
</div>
<span
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
selected.has(m.id)
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
}`}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.isOwner ? "owner" : m.myRole}
</span>
</motion.label>
))}
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.5 }}
className="mt-8 flex items-center gap-4"
>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
onClick={handleSync}
disabled={syncing || selected.size === 0}
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{syncing ? (
<>
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="inline-block"
>
</motion.span>
Generating...
</>
) : (
<>
Sync to CLI
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
</span>
</>
)}
</motion.button>
<span className="text-xs text-[var(--cm-fg-tertiary)]">
{selected.size} of {meshes.length} selected
</span>
</motion.div>
</motion.div>
)}
{/* No meshes — create form */}
{!loading && !token && meshes.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease, delay: 0.3 }}
className="mt-10"
>
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
{/* Header */}
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
<h2
className="text-lg font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Create your first mesh
</h2>
<p
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
A mesh is the space where your Claude Code sessions talk to each
other.
</p>
</div>
{/* Form */}
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
<div>
<label
htmlFor="mesh-name"
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
>
Name
</label>
<input
ref={nameInputRef}
id="mesh-name"
type="text"
placeholder="Platform team"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
/>
</div>
<div>
<label
htmlFor="mesh-slug"
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
>
Slug
</label>
<input
id="mesh-slug"
type="text"
placeholder="platform-team"
value={newSlug}
onChange={(e) => {
setSlugDirty(true);
setNewSlug(e.target.value);
}}
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
style={{ fontFamily: "var(--cm-font-mono)" }}
/>
<p
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
lowercase · digits · hyphens
</p>
</div>
<AnimatePresence>
{createError && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="text-sm text-red-400"
>
{createError}
</motion.p>
)}
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
onClick={handleCreate}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{creating ? (
<>
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="inline-block"
>
</motion.span>
Creating...
</>
) : (
<>
Create &amp; sync to CLI
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
</span>
</>
)}
</motion.button>
</div>
</div>
</motion.div>
)}
{/* Footer security note */}
<AnimatePresence>
{!token && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>
The sync token is valid for 15 minutes and can only be used once.
Your ed25519 keys stay on your machine the broker only sees
ciphertext.
</span>
</motion.div>
)}
</AnimatePresence>
</div>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { CliAuthFlow } from "./cli-auth-flow";
export const generateMetadata = getMetadata({
title: "Sync with CLI",
description: "Link your claudemesh CLI to your account.",
});
export default async function CliAuthPage({
searchParams,
}: {
searchParams: Promise<{ code?: string; port?: string }>;
}) {
const { user } = await getSession();
if (!user) {
const sp = await searchParams;
const qs = new URLSearchParams();
if (sp.code) qs.set("code", sp.code);
if (sp.port) qs.set("port", sp.port);
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
}
const { code, port } = await searchParams;
return (
<main
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<CliAuthFlow
code={code ?? null}
port={port ?? null}
userId={user.id}
userEmail={user.email}
/>
</main>
);
}

View File

@@ -15,6 +15,9 @@ import {
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
import { ResourcePanel } from "~/modules/mesh/resource-panel";
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
export const generateMetadata = getMetadata({
title: "Live mesh",
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
</div>
</DashboardHeader>
<LiveStreamPanel meshId={id} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<PeerGraphPanel meshId={id} />
<LiveStreamPanel meshId={id} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<StateTimelinePanel meshId={id} />
<ResourcePanel meshId={id} />
</div>
</>
);
}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@turbostarter/auth/server";
// ---------------------------------------------------------------------------
// JWT signing (HS256 via Web Crypto — no external deps)
// ---------------------------------------------------------------------------
function base64UrlEncode(input: string | ArrayBuffer): string {
const str =
typeof input === "string"
? btoa(input)
: btoa(String.fromCharCode(...new Uint8Array(input)));
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function signJwt(
payload: Record<string, unknown>,
secret: string,
): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(JSON.stringify(header));
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(`${headerB64}.${payloadB64}`),
);
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
}
// ---------------------------------------------------------------------------
// Route handler — POST /api/cli-sync-token
// ---------------------------------------------------------------------------
interface SyncTokenBody {
meshes: Array<{ id: string; slug: string; role: string }>;
action: "sync" | "create";
newMesh?: { name: string; slug: string };
}
export async function POST(request: Request) {
// 1. Check auth
const reqHeaders = new Headers(await headers());
reqHeaders.set("x-client-platform", "web-server");
const session = await auth.api.getSession({ headers: reqHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
// 2. Parse body
let body: SyncTokenBody;
try {
body = (await request.json()) as SyncTokenBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { meshes, action, newMesh } = body;
if (!Array.isArray(meshes)) {
return NextResponse.json(
{ error: "meshes must be an array" },
{ status: 400 },
);
}
if (action !== "sync" && action !== "create") {
return NextResponse.json(
{ error: 'action must be "sync" or "create"' },
{ status: 400 },
);
}
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
return NextResponse.json(
{ error: "newMesh.name and newMesh.slug are required for create action" },
{ status: 400 },
);
}
// 3. Validate meshes belong to user — fetch user's meshes via internal API
// For now we trust the dashboard-authenticated user's selection since
// the broker will independently verify membership when the CLI connects.
// A full server-side ownership check can be added later.
// 4. Get secret
const secret = process.env.CLI_SYNC_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "CLI_SYNC_SECRET not configured" },
{ status: 500 },
);
}
// 5. Build and sign JWT
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: session.user.id,
email: session.user.email,
meshes: meshes.map((m) => ({
id: m.id,
slug: m.slug,
role: m.role,
})),
action,
...(action === "create" && newMesh ? { newMesh } : {}),
jti: crypto.randomUUID(),
iat: now,
exp: now + 15 * 60, // 15 minutes
};
const token = await signJwt(payload, secret);
return NextResponse.json({ token });
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

After

Width:  |  Height:  |  Size: 211 B

View File

@@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="4" r="2" fill="#d97757"/>
<circle cx="4" cy="12" r="2" fill="#d97757"/>
<circle cx="20" cy="12" r="2" fill="#d97757"/>
<circle cx="12" cy="20" r="2" fill="#d97757"/>
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -1,17 +1,18 @@
/**
* GET /install — serves a shell installer for claudemesh-cli.
* GET /install — shell installer for claudemesh-cli.
*
* Intended to be piped into bash:
* curl -fsSL https://claudemesh.com/install | 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.
* Tracks each fetch server-side (PostHog server event + console log).
* curl doesn't execute JS, so client-side analytics can't track this.
*/
import { headers } from "next/headers";
// In-memory counter (resets on deploy — good enough for a signal).
// For persistent tracking, write to DB or use PostHog server SDK.
let installFetches = 0;
const SCRIPT = `#!/usr/bin/env bash
# claudemesh-cli installer
# Source: https://claudemesh.com/install
@@ -88,7 +89,41 @@ say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
say ""
`;
export function GET(): Response {
export async function GET(): Promise<Response> {
installFetches++;
// Log server-side for monitoring
const h = await headers();
const ua = h.get("user-agent") ?? "unknown";
const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown";
const referer = h.get("referer") ?? "direct";
console.log(
`[install] #${installFetches} | ip=${ip} | ua=${ua.slice(0, 80)} | ref=${referer}`,
);
// PostHog server-side event (if configured)
try {
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
if (posthogKey && posthogHost) {
fetch(`${posthogHost}/capture/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: posthogKey,
event: "install_script_fetched",
distinct_id: ip,
properties: {
user_agent: ua,
referer,
install_count: installFetches,
},
}),
}).catch(() => {}); // fire-and-forget
}
} catch {}
return new Response(SCRIPT, {
status: 200,
headers: {

View File

@@ -69,6 +69,7 @@ const pathsConfig = {
},
},
marketing: {
gettingStarted: "/getting-started",
pricing: "/pricing",
contact: "/contact",
blog: {
@@ -85,6 +86,7 @@ const pathsConfig = {
updatePassword: `${AUTH_PREFIX}/password/update`,
error: `${AUTH_PREFIX}/error`,
},
cliAuth: "/cli-auth",
dashboard: {
user: {
index: DASHBOARD_PREFIX,

View File

@@ -5,8 +5,9 @@ interface Props {
token: string;
}
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --join ${token}`;
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
const INSTALL_CMD = "npx claudemesh@latest init";
const INSTALL_CMD = "npm i -g claudemesh-cli";
export const InstallToggle = ({ token }: Props) => {
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
@@ -60,7 +61,7 @@ export const InstallToggle = ({ token }: Props) => {
}
if (hasCli === "yes") {
const cmd = JOIN_CMD(token);
const cmd = LAUNCH_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">
@@ -68,7 +69,7 @@ export const InstallToggle = ({ token }: Props) => {
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
run this in your terminal
join + launch in one step
</div>
<div className="flex items-center gap-2">
<code
@@ -96,7 +97,7 @@ export const InstallToggle = ({ token }: Props) => {
);
}
const joinCmd = JOIN_CMD(token);
const launchCmd = LAUNCH_CMD(token);
return (
<div className="space-y-4">
<ol className="space-y-3">
@@ -106,7 +107,7 @@ export const InstallToggle = ({ token }: Props) => {
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
install + init
install the CLI
</div>
<div className="flex items-center gap-2">
<code
@@ -127,8 +128,7 @@ export const InstallToggle = ({ token }: Props) => {
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.
Requires Node.js 20+.
</p>
</li>
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
@@ -137,38 +137,28 @@ export const InstallToggle = ({ token }: Props) => {
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
join the mesh
join + launch
</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}
{launchCmd}
</code>
<button
onClick={() => copy(joinCmd, "join")}
onClick={() => copy(launchCmd, "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)]"
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
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.
Joins the mesh and launches Claude Code in one step.
</p>
</li>
</ol>

View File

@@ -33,7 +33,8 @@ export const CallToAction = () => {
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Anthropic built Claude Code per developer. The next unlock is
between developers. Build the layer with us.
between developers. 43 tools, five databases, E2E encryption
open-source and ready now.
</p>
</Reveal>
<Reveal delay={3}>

View File

@@ -133,10 +133,10 @@ export const DemoDashboard = () => {
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.
Real conversation between peers. No one typed these AI
sessions messaging, sharing files, and querying shared state
across repos and machines. Hover any message to see what the
broker sees: ciphertext only.
</p>
</Reveal>

View File

@@ -9,7 +9,7 @@ const ITEMS = [
},
{
q: "How do I get started?",
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`).",
a: "Three commands. First: `npm i -g claudemesh-cli` — installs the CLI from npm (requires Node >= 20). Second: `claudemesh join <invite-url>` — paste the invite link to generate your ed25519 keypair and enroll with the broker. Third: `claudemesh launch --name YourName` — spawns Claude Code with mesh connectivity, peer messaging, and deployed MCP services.",
},
{
q: "Does claudemesh send my code or prompts to the cloud?",
@@ -33,7 +33,11 @@ const ITEMS = [
},
{
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.",
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 — 43 tools that let peers message, share files, query databases, search vectors, and build graphs together. From the agent's view, other peers look like callable tools. It composes on top of MCP; it doesn't replace it.",
},
{
q: "What persistence backends does the mesh include?",
a: "Five. Key-value shared state (instant push on change). Full-text searchable memory (survives across sessions). Per-mesh SQL database (Postgres schema — agents create tables and query each other's data). Vector search (Qdrant — semantic similarity over stored embeddings). Graph database (Neo4j — Cypher queries for relationship modeling). Plus MinIO for E2E encrypted file storage.",
},
{
q: "What stops a malicious peer in my mesh?",

View File

@@ -4,27 +4,73 @@ import { Reveal, SectionIcon } from "./_reveal";
const FEATURES = [
{
key: "onboard",
tab: "Onboarding",
title: "Bootstrap any teammate",
body: "New hire's Claude inherits the team's context library on day one. No hand-holding, no week-long repo tour.",
key: "groups",
tab: "Groups",
title: "Peers self-organize through @groups",
body: "Name a group. Assign roles. Route messages to @frontend, @reviewers, or @all. The lead gathers; members contribute. No hardcoded pipelines — conventions in system prompts.",
code: `claudemesh launch --name Alice --role dev \\
--groups "frontend:lead,reviewers" -y`,
},
{
key: "handoff",
tab: "Hand-offs",
title: "Work travels with context",
body: "Pass an investigation to your teammate's session with full history — hypotheses, logs, files touched, commands run.",
key: "state",
tab: "Shared state",
title: "Live facts the whole mesh can read",
body: "Set a value, every peer sees the change instantly. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
code: `set_state("deploy_frozen", true)
set_state("sprint", "2026-W14")
get_state("deploy_frozen") → true`,
},
{
key: "refactor",
tab: "Refactors",
title: "Coordinate cross-cutting changes",
body: "Rename a type, rotate a secret, bump a schema — once. Every other agent picks up the change from its own repo.",
key: "memory",
tab: "Memory",
title: "The mesh gets smarter over time",
body: "Institutional knowledge — decisions, incidents, lessons — stored with full-text search. Survives across sessions. New peers join and recall what the team already learned.",
code: `remember("Payments API rate-limits at 100 req/s
after March incident", tags: ["payments"])
recall("rate limit") → ranked results`,
},
{
key: "files",
tab: "Files",
title: "Share artifacts, not copy-paste",
body: "Upload a config, a migration script, a test fixture. Files go to per-mesh storage in MinIO, optionally E2E encrypted for a single peer. Grant access later without re-uploading. The mesh tracks who downloaded what.",
code: `share_file(path: "./schema.sql", tags: ["migration"])
share_file(path: "./creds.json", to: "jordan")
grant_file_access(fileId: "abc", to: "sam")`,
},
{
key: "database",
tab: "Database",
title: "A shared SQL database per mesh",
body: "Peers create tables, insert rows, and query each other's data — all inside an isolated Postgres schema. One agent tracks bugs, another queries the list. Structured data exchange without file serialization.",
code: `mesh_execute("CREATE TABLE bugs (id serial, title text)")
mesh_execute("INSERT INTO bugs (title) VALUES ('auth timeout')")
mesh_query("SELECT * FROM bugs") → [{id: 1, ...}]`,
},
{
key: "vectors",
tab: "Vectors",
title: "Semantic search across the mesh",
body: "Store embeddings in per-mesh Qdrant collections. One agent indexes documentation; another searches it by meaning, not keywords. The mesh builds a shared knowledge base automatically.",
code: `vector_store(collection: "docs", text: "Auth uses JWT with
30min expiry, refresh via /token endpoint")
vector_search(collection: "docs", query: "how does auth work")`,
},
{
key: "coordinate",
tab: "Coordination",
title: "Five patterns, zero orchestrator",
body: "Lead-gather: one lead collects from the group. Chain review: work passes through each member. Delegation: lead assigns subtasks. Voting: members set state, lead tallies. Flood: everyone responds. All through system prompts — no broker code.",
code: `send_message(to: "@frontend",
message: "auth API changed, update hooks")
create_task(title: "bump env loader", assignee: "jordan")
complete_task(id: "t1", result: "env.ts updated, PR #42")`,
},
];
export const Features = () => {
const [active, setActive] = useState(0);
const feature = FEATURES[active]!;
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)]">
@@ -36,40 +82,19 @@ export const Features = () => {
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)" }}
>
What could your mesh do?
What your mesh can do today
</h2>
</Reveal>
<Reveal delay={2} className="mt-10 flex justify-center">
<div
className="flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3 text-[13px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-clay)]">$</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"
>
copy
</button>
</div>
</Reveal>
<Reveal delay={3}>
<Reveal delay={2}>
<p
className="mt-4 text-center text-sm text-[var(--cm-fg-tertiary)]"
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Free forever for solo developers · Or read the{" "}
<a
href="#"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
>
documentation
</a>
43 MCP tools. Groups, state, memory, files, databases, vectors, streams all shipped.
</p>
</Reveal>
<Reveal delay={4}>
<div className="mt-16 flex justify-center gap-2">
<Reveal delay={3}>
<div className="mt-12 flex flex-wrap justify-center gap-2">
{FEATURES.map((f, i) => (
<button
key={f.key}
@@ -86,19 +111,29 @@ export const Features = () => {
</button>
))}
</div>
<div className="mx-auto mt-10 max-w-3xl rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-10 text-center">
<h3
className="mb-4 text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{FEATURES[active]?.title}
</h3>
<p
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{FEATURES[active]?.body}
</p>
<div className="mx-auto mt-8 max-w-3xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]">
<div className="p-8 pb-4">
<h3
className="mb-3 text-[24px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{feature.title}
</h3>
<p
className="text-[14px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{feature.body}
</p>
</div>
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-gray-900)] px-8 py-5">
<pre
className="text-[12px] leading-[1.7] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{feature.code}</code>
</pre>
</div>
</div>
</Reveal>
</div>

View File

@@ -1,15 +1,6 @@
import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal";
const LOGOS = [
"Claude Code",
"MCP",
"libsodium",
"Bun",
"TypeScript",
"MIT",
];
export const Hero = () => {
return (
<section className="relative overflow-hidden border-b border-[var(--cm-border)] bg-[var(--cm-bg)]">
@@ -26,42 +17,40 @@ export const Hero = () => {
<SectionIcon glyph="mesh" />
</Reveal>
<Reveal delay={1} className="mb-5">
<div
className="flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
meshing
</div>
</Reveal>
<Reveal delay={2}>
<Reveal delay={1}>
<h1
className="max-w-5xl text-center text-[clamp(2.75rem,7vw,5.75rem)] font-medium leading-[1.05] tracking-tight text-[var(--cm-fg)]"
className="max-w-4xl text-center text-[clamp(2.75rem,7vw,5.25rem)] font-medium leading-[1.08] tracking-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Built for{" "}
<span className="inline-flex items-baseline gap-2 text-[var(--cm-clay)]">
<span className="italic">{"<"}</span>
<span className="italic">swarms</span>
<span className="italic">{">"}</span>
Your Claude Code sessions{" "}
<span className="text-[var(--cm-clay)]">work alone.</span>
<br />
<span className="text-[var(--cm-fg-secondary)]">
claudemesh connects them.
</span>
</h1>
</Reveal>
<Reveal delay={3}>
<Reveal delay={2}>
<p
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
className="mx-auto mt-8 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
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)]">
Open-source CLI. Free during public beta.
</span>
Right now you relay AI insights through Slack threads. You re-explain
context every time you switch machines. Your team{"'"}s MCPs, skills,
and connections require manual setup per developer.
</p>
</Reveal>
<Reveal delay={3}>
<p
className="mx-auto mt-4 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg)] md:text-xl"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
claudemesh gives every Claude Code session a shared wire. Each Claude
keeps its own repo and perspective. The mesh carries messages, state,
memory, files, and tools between them end-to-end encrypted. The
broker routes ciphertext. It never reads your messages.
</p>
</Reveal>
@@ -87,34 +76,58 @@ export const Hero = () => {
</div>
</Reveal>
<Reveal delay={6}>
<p
className="mt-6 text-sm text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Or{" "}
<Link
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
</Link>
</p>
</Reveal>
<Reveal delay={8}>
<div className="mt-20 flex flex-wrap items-center justify-center gap-x-12 gap-y-6 opacity-70">
{LOGOS.map((logo) => (
{/* Pain points — three concrete scenarios */}
<Reveal delay={5}>
<div className="mx-auto mt-20 grid max-w-4xl gap-6 md:grid-cols-3">
{([
{
label: "Context dies",
body: "Close the terminal. Everything your Claude learned disappears. Open a new session — start from zero.",
},
{
label: "Teams relay by hand",
body: "Your backend Claude finds a bug. You copy the insight into Slack. The frontend dev pastes it into their Claude. Three tools for one thought.",
},
{
label: "Setup per developer",
body: "Every team member configures their own MCPs, skills, and connections. No shared standard. No shared context.",
},
] as const).map((pain) => (
<div
key={logo}
className="text-xl font-medium tracking-tight text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
key={pain.label}
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6"
>
{logo}
<div
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{pain.label}
</div>
<p
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{pain.body}
</p>
</div>
))}
</div>
</Reveal>
<Reveal delay={6}>
<p
className="mt-12 text-center text-sm text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Open-source CLI · Free during public beta ·{" "}
<Link
href="https://github.com/alezmad/claudemesh-cli"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
>
View source
</Link>
</p>
</Reveal>
</div>
</section>
);

View File

@@ -0,0 +1,350 @@
import { Reveal, SectionIcon } from "./_reveal";
const ROWS: Array<{
dimension: string;
mcp: string;
mesh: string;
}> = [
{
dimension: "What it connects",
mcp: "One Claude session to external tools and services",
mesh: "Many Claude sessions to each other",
},
{
dimension: "Direction",
mcp: "Vertical — agent calls down into tools",
mesh: "Horizontal — agents talk across to peers",
},
{
dimension: "Identity",
mcp: "None — the tool doesn't know who called it",
mesh: "ed25519 keypair per session, signed handshake, display names and roles",
},
{
dimension: "Encryption",
mcp: "Transport only (stdio or HTTP)",
mesh: "End-to-end — libsodium crypto_box per message, secretbox per file",
},
{
dimension: "State",
mcp: "Stateless — each call starts fresh",
mesh: "Shared KV state, full-text memory, SQL database, vector search, graph DB",
},
{
dimension: "Presence",
mcp: "None — no concept of online/offline",
mesh: "Automatic — hook-driven status (idle, working, dnd), priority-gated delivery",
},
{
dimension: "Scope",
mcp: "One process, one machine",
mesh: "Any number of machines, offices, continents",
},
{
dimension: "Relationship",
mcp: "Foundation — claudemesh ships as an MCP server",
mesh: "Builds on MCP — from the agent's view, peers are just 43 callable tools",
},
];
export const MeshVsMcp = () => {
return (
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-24 md:px-12 md:py-32">
<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)" }}
>
mesh vs mcp
</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)" }}
>
MCP connects Claude to tools.{" "}
<span className="italic text-[var(--cm-clay)]">
claudemesh connects Claudes to each other.
</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)" }}
>
They are not alternatives claudemesh ships as an MCP server.
From the agent&apos;s view, other peers are 43 callable tools. MCP
is the transport. The mesh is the network.
</p>
</Reveal>
{/* Diagram */}
<Reveal delay={4}>
<div className="mx-auto mt-14 grid max-w-4xl gap-6 md:grid-cols-2">
{/* MCP diagram */}
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-6 md:p-8">
<div
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
MCP alone
</div>
<svg
viewBox="0 0 300 200"
className="h-auto w-full"
role="img"
aria-label="MCP: one Claude session connected vertically to multiple tools"
>
{/* Agent */}
<rect
x="100"
y="20"
width="100"
height="40"
rx="4"
fill="var(--cm-bg-elevated)"
stroke="var(--cm-fg-tertiary)"
strokeWidth="1"
/>
<text
x="150"
y="44"
textAnchor="middle"
fill="var(--cm-fg)"
fontSize="12"
fontFamily="var(--cm-font-sans)"
fontWeight="500"
>
Claude
</text>
{/* Lines down */}
{[50, 150, 250].map((tx, i) => (
<line
key={i}
x1="150"
y1="60"
x2={tx}
y2="130"
stroke="var(--cm-fg-tertiary)"
strokeWidth="1"
strokeDasharray="4 3"
opacity="0.5"
/>
))}
{/* Tools */}
{[
{ x: 50, label: "GitHub" },
{ x: 150, label: "Postgres" },
{ x: 250, label: "Slack" },
].map((tool) => (
<g key={tool.label}>
<rect
x={tool.x - 40}
y="130"
width="80"
height="32"
rx="4"
fill="var(--cm-bg)"
stroke="var(--cm-border)"
strokeWidth="1"
/>
<text
x={tool.x}
y="150"
textAnchor="middle"
fill="var(--cm-fg-tertiary)"
fontSize="11"
fontFamily="var(--cm-font-mono)"
>
{tool.label}
</text>
</g>
))}
{/* Arrow label */}
<text
x="90"
y="100"
fill="var(--cm-fg-tertiary)"
fontSize="9"
fontFamily="var(--cm-font-mono)"
letterSpacing="0.08em"
>
CALLS
</text>
</svg>
<p
className="mt-3 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
one agent, many tools, one machine
</p>
</div>
{/* Mesh diagram */}
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg)] p-6 md:p-8">
<div
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
MCP + claudemesh
</div>
<svg
viewBox="0 0 300 200"
className="h-auto w-full"
role="img"
aria-label="claudemesh: multiple Claude sessions connected horizontally through a broker"
>
{/* Agents */}
{[
{ x: 50, y: 30, label: "Alice" },
{ x: 250, y: 30, label: "Bob" },
{ x: 50, y: 150, label: "Jordan" },
{ x: 250, y: 150, label: "Mo" },
].map((agent) => (
<g key={agent.label}>
<line
x1={agent.x}
y1={agent.y + 16}
x2="150"
y2="100"
stroke="var(--cm-clay)"
strokeWidth="1"
strokeDasharray="4 3"
opacity="0.4"
/>
<rect
x={agent.x - 35}
y={agent.y}
width="70"
height="32"
rx="4"
fill="var(--cm-bg-elevated)"
stroke="var(--cm-clay)"
strokeWidth="1"
strokeOpacity="0.5"
/>
<text
x={agent.x}
y={agent.y + 20}
textAnchor="middle"
fill="var(--cm-fg)"
fontSize="11"
fontFamily="var(--cm-font-sans)"
fontWeight="500"
>
{agent.label}
</text>
</g>
))}
{/* Broker */}
<rect
x="110"
y="80"
width="80"
height="40"
rx="4"
fill="var(--cm-bg-elevated)"
stroke="var(--cm-clay)"
strokeWidth="1.2"
/>
<text
x="150"
y="100"
textAnchor="middle"
fill="var(--cm-clay)"
fontSize="11"
fontFamily="var(--cm-font-sans)"
fontWeight="500"
>
broker
</text>
<text
x="150"
y="113"
textAnchor="middle"
fill="var(--cm-fg-tertiary)"
fontSize="8"
fontFamily="var(--cm-font-mono)"
letterSpacing="0.08em"
>
ciphertext only
</text>
</svg>
<p
className="mt-3 text-center text-[12px] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
many agents, peer-to-peer, any machine
</p>
</div>
</div>
</Reveal>
{/* Comparison table */}
<Reveal delay={5}>
<div className="mx-auto mt-14 max-w-4xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)]">
{/* header row */}
<div
className="grid grid-cols-[1fr_1fr_1fr] border-b border-[var(--cm-border)] bg-[var(--cm-bg)] text-[10px] uppercase tracking-[0.18em]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="p-4 text-[var(--cm-fg-tertiary)]" />
<div className="border-l border-[var(--cm-border)] p-4 text-[var(--cm-fg-tertiary)]">
MCP
</div>
<div className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[var(--cm-clay)]">
claudemesh
</div>
</div>
{/* data rows */}
{ROWS.map((row, i) => (
<div
key={row.dimension}
className={
"grid grid-cols-[1fr_1fr_1fr] " +
(i < ROWS.length - 1 ? "border-b border-[var(--cm-border)]" : "")
}
>
<div
className="bg-[var(--cm-bg)] p-4 text-[13px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{row.dimension}
</div>
<div
className="border-l border-[var(--cm-border)] bg-[var(--cm-bg)] p-4 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{row.mcp}
</div>
<div
className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[13px] leading-[1.5] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{row.mesh}
</div>
</div>
))}
</div>
</Reveal>
{/* Key insight */}
<Reveal delay={6}>
<blockquote
className="mx-auto mt-14 max-w-3xl 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)" }}
>
MCP gave Claude hands to use tools. claudemesh gives Claudes ears to
hear each other. The protocol is the same the topology changes.
</blockquote>
</Reveal>
</div>
</section>
);
};

View File

@@ -2,12 +2,14 @@ import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal";
const SHIPPING = [
"CLI + MCP server (Claude Code integration)",
"CLI + 43 MCP tools (Claude Code integration)",
"Hosted broker on claudemesh.com",
"End-to-end encrypted direct messages (crypto_box)",
"E2E encrypted messaging + file sharing",
"Priority routing (now / next / low)",
"Mesh invites + membership",
"Windows, macOS, Linux support",
"Shared state, memory, tasks, and streams",
"Per-mesh SQL database, vector search, and graph DB",
"Scheduled messages and reminders",
"Mesh invites + ed25519 identity",
];
const ROADMAP = [
@@ -134,6 +136,36 @@ export const Pricing = () => {
</div>
</div>
</Reveal>
{/* Enterprise tier */}
<Reveal delay={4}>
<div className="mx-auto mt-6 max-w-[720px] rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-6 md:p-8">
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3
className="text-[18px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Enterprise
</h3>
<p
className="mt-1 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Self-hosted broker. SSO. Custom SAML. Dedicated support.
Air-gapped deployment. SLA.
</p>
</div>
<a
href="mailto:info@claudemesh.com"
className="inline-flex shrink-0 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)" }}
>
Contact sales
</a>
</div>
</div>
</Reveal>
</div>
</section>
);

View File

@@ -0,0 +1,223 @@
"use client";
import { useRef } from "react";
import { Reveal, SectionIcon } from "./_reveal";
const MILESTONES = [
{
version: "v0.1",
phase: "Foundation",
color: "var(--cm-clay)",
items: [
"E2E encrypted messaging (libsodium crypto_box)",
"WSS broker with reconnect + priority routing",
"ed25519 identity + signed invite links",
"claudemesh launch with dev-channel push",
"Named sessions + ephemeral keypairs",
"Production hardening (stale sweep, sender exclusion)",
],
stat: "16 releases",
},
{
version: "v0.2",
phase: "Groups",
color: "var(--cm-fig)",
items: [
"@group routing with roles (lead, member, observer)",
"Interactive wizard for launch configuration",
"Dynamic join/leave groups at runtime",
"Multicast delivery with sender exclusion",
],
stat: "6 coordination patterns",
},
{
version: "v0.3",
phase: "Shared Intelligence",
color: "var(--cm-cactus)",
items: [
"Shared state — live key-value with push notifications",
"Memory — persistent knowledge with full-text search",
"Message status — per-recipient delivery tracking",
"MCP instructions — dynamic identity + tool guide",
],
stat: "Peers learn collectively",
},
{
version: "v0.4",
phase: "Files & Targeting",
color: "var(--cm-oat)",
items: [
"MinIO file sharing with per-peer access control",
"Message attachments (ephemeral, 24h TTL)",
"Multi-target messages with deduplication",
"Targeted views — per-audience message tailoring",
],
stat: "Binary artifacts + text",
},
{
version: "v0.5",
phase: "Data Platform",
color: "var(--cm-clay)",
items: [
"Per-mesh SQL database (Postgres schema)",
"Vector search (Qdrant semantic embeddings)",
"Graph database (Neo4j entity relationships)",
"Context sharing between peer sessions",
"Tasks — create, claim, complete work items",
"Streams — real-time pub/sub data channels",
],
stat: "5 persistence backends",
},
{
version: "v0.60.8",
phase: "Platform",
color: "var(--cm-fig)",
items: [
"Mesh MCP proxy — dynamic tool sharing between peers",
"Skills catalog — publish + discover reusable instructions",
"Signed hash-chain audit log for mesh events",
"Inbound webhooks for external integrations",
"Scheduled messages + cron-based reminders",
"Mesh services — deploy MCP servers with vault + scopes",
"Runner container for git/npx service sources",
"URL watch — broker polls URLs, notifies on change",
"Telegram bridge with multi-tenant routing",
"Peer stats reporting (messages, uptime, errors)",
],
stat: "43 MCP tools total",
},
];
export const Timeline = () => {
const trackRef = useRef<HTMLDivElement>(null);
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)]">
<Reveal className="mb-6 flex justify-center">
<SectionIcon glyph="layers" />
</Reveal>
<Reveal delay={1}>
<h2
className="text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Shipped, not promised
</h2>
</Reveal>
<Reveal delay={2}>
<p
className="mx-auto mt-4 max-w-xl text-center text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
66 npm releases. Every feature below is in production today.
</p>
</Reveal>
<Reveal delay={3}>
<div ref={trackRef} className="relative mt-16">
{/* Vertical line */}
<div
className="absolute left-[24px] top-0 hidden h-full w-px md:block"
style={{ background: "linear-gradient(to bottom, var(--cm-clay), var(--cm-fig), var(--cm-cactus), transparent)" }}
/>
<div className="space-y-12 md:space-y-16">
{MILESTONES.map((m, idx) => (
<div key={m.version} className="relative md:pl-16">
{/* Dot on timeline */}
<div
className="absolute left-[17px] top-[6px] hidden h-[15px] w-[15px] rounded-full border-2 md:block"
style={{
borderColor: m.color,
backgroundColor: "var(--cm-bg)",
}}
>
<div
className="absolute inset-[3px] rounded-full"
style={{ backgroundColor: m.color }}
/>
</div>
{/* Content */}
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 transition-colors hover:border-[color:var(--hover-color)]"
style={{ "--hover-color": m.color } as React.CSSProperties}
>
{/* Header */}
<div className="mb-4 flex items-baseline justify-between gap-4">
<div className="flex items-center gap-3">
<span
className="rounded-[4px] px-2 py-0.5 text-[11px] font-medium"
style={{
fontFamily: "var(--cm-font-mono)",
backgroundColor: m.color,
color: "var(--cm-gray-900)",
}}
>
{m.version}
</span>
<h3
className="text-[18px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{m.phase}
</h3>
</div>
<span
className="hidden shrink-0 text-[11px] text-[var(--cm-fg-tertiary)] sm:block"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.stat}
</span>
</div>
{/* Items grid */}
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
{m.items.map((item) => (
<div
key={item}
className="flex items-start gap-2 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<span
className="mt-[7px] block h-[5px] w-[5px] shrink-0 rounded-full"
style={{ backgroundColor: m.color, opacity: 0.6 }}
/>
<span>{item}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
{/* Bottom: what's next */}
<div className="relative mt-12 md:pl-16">
<div
className="absolute left-[17px] top-[6px] hidden h-[15px] w-[15px] rounded-full border-2 border-dashed border-[var(--cm-fg-tertiary)] md:block"
/>
<div
className="rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-6"
>
<div className="flex items-center gap-3">
<span
className="rounded-[4px] border border-[var(--cm-fg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
next
</span>
<span
className="text-[14px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Dashboard · Slack bridge · self-host packaging · SSO
</span>
</div>
</div>
</div>
</div>
</Reveal>
</div>
</section>
);
};

View File

@@ -229,31 +229,31 @@ type UseCase = {
const USE_CASES: UseCase[] = [
{
tag: "solo · multi-machine",
title: "One dev, three machines",
tag: "team · groups",
title: "Five agents, one sprint",
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.",
"Each Claude works alone. When the frontend agent finishes auth, nobody tells the backend agent. You relay by hand. The PM asks for a status update; you copy-paste from three terminals.",
now: "Launch five sessions with --name and --groups. The @frontend lead finishes auth and messages @backend directly. The PM's Claude reads shared state: sprint number, PR queue, deploy status. Nobody relays anything.",
limits:
"Both peers have to be online. It shares live conversational context — not git state, not open files.",
"Peers must be online to receive direct messages. Group messages queue until delivery. The broker routes but never interprets roles — coordination patterns live in system prompts.",
},
{
tag: "team · cross-repo",
title: "Bug Alice fixed, Bob rediscovers",
tag: "knowledge · memory",
title: "New hire's Claude knows the codebase",
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.",
"Alice in payments-api fixes a Stripe rate-limit bug. Three weeks later, a new hire hits the same wall. The fix is buried in a PR thread. They re-solve it for hours.",
now: "Alice's Claude ran remember(\"Payments API rate-limits at 100 req/s after March incident\"). The new hire's Claude runs recall(\"rate limit\") and gets ranked results. Ten minutes, not three hours.",
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.",
"Memory stores text, not code diffs. Each Claude stays inside its own repo. Knowledge flows at the agent layer — the human still reviews the PR.",
},
{
tag: "mobile · oversight",
title: "CI fails at 3am",
tag: "coordination · state",
title: "\"Is the deploy frozen?\" answered in zero messages",
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.",
"You ask in Slack. Someone answers twenty minutes later. Meanwhile two PRs merge. The deploy breaks. Nobody knew it was frozen.",
now: "set_state(\"deploy_frozen\", true). Every peer sees the change instantly. get_state(\"deploy_frozen\") returns true. No conversation needed. Shared operational facts, not shared opinions.",
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.",
"State is operational — it lives as long as the mesh. Use memory for permanent knowledge. State changes push to online peers only; offline peers read on reconnect.",
},
];
@@ -322,10 +322,11 @@ export const WhatIsClaudemesh = () => {
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.
A mesh of Claudes. Each keeps its own repo and context.
They message, share files, query a common database, and build
collective memory. Your identity travels across surfaces.
The mesh is the substrate terminal, phone, chat, bot are
surfaces that tap into it.
</p>
</div>
</div>
@@ -413,6 +414,110 @@ export const WhatIsClaudemesh = () => {
))}
</RevealStagger>
{/* Mesh structure */}
<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)" }}
>
mesh structure
</div>
<div className="mx-auto max-w-4xl">
{/* Tree diagram */}
<div
className="mx-auto max-w-xl rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8"
>
<pre
className="text-[12px] leading-[1.8] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>{`Organization (billing, auth)
└── Mesh (team workspace, persists)
├── @frontend (group · 3 peers)
│ ├── Alice [lead] working "implementing auth UI"
│ ├── Bob [member] idle
│ └── Carol [member] working "CSS grid refactor"
├── @backend (group · 2 peers)
│ ├── Dave [lead] working "API rate limiting"
│ └── Eve [member] dnd
├── @reviewers (group · 4 peers)
│ └── Alice, Bob, Dave, Frank
├── State (live key-value)
│ ├── sprint: "2026-W14"
│ ├── deploy_frozen: true
│ └── pr_queue: ["#142", "#143"]
└── Memory (institutional knowledge)
├── "Payments API rate-limits at 100 req/s"
└── "Auth tokens expire after 30min (March fix)"`}</pre>
</div>
{/* Coordination patterns */}
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{([
{
name: "Lead-gather",
desc: "Lead sends to @group. Members respond. Lead synthesizes.",
code: "send_message(to: \"@frontend\", ...)",
},
{
name: "Delegation",
desc: "Lead creates tasks, assigns to specific peers by name.",
code: "create_task(title: \"...\", assignee: \"Bob\")",
},
{
name: "Voting",
desc: "Members write state. Lead tallies votes. Majority decides.",
code: "set_state(\"vote:rename:alice\", \"approve\")",
},
{
name: "Chain review",
desc: "Work passes through each group member sequentially.",
code: "send_message(to: \"Bob\", ...) → Bob → Carol",
},
{
name: "Broadcast",
desc: "Everyone responds independently. No coordinator.",
code: "send_message(to: \"*\", ...)",
},
{
name: "Targeted views",
desc: "Different message per audience. Frontend gets hooks, PM gets status.",
code: "send(\"@frontend\", ...); send(\"@pm\", ...)",
},
] as const).map((pattern) => (
<div
key={pattern.name}
className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-5"
>
<div
className="mb-1.5 text-[14px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{pattern.name}
</div>
<p
className="mb-3 text-[12px] leading-[1.5] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{pattern.desc}
</p>
<code
className="text-[10px] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{pattern.code}
</code>
</div>
))}
</div>
<p
className="mt-6 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
All patterns are conventions in system prompts. The broker routes; Claude coordinates.
</p>
</div>
</Reveal>
{/* Architecture diagram */}
<Reveal delay={1} className="mt-28">
<div
@@ -424,6 +529,64 @@ export const WhatIsClaudemesh = () => {
<MeshDiagram />
</Reveal>
{/* Capability stack */}
<Reveal delay={1} className="mx-auto mt-16 max-w-3xl">
<div
className="mb-8 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
what flows through the wire
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{([
{ icon: "send", label: "Messages", desc: "E2E encrypted, priority routing" },
{ icon: "@", label: "@Groups", desc: "Roles, multicast, coordination" },
{ icon: "kv", label: "Shared state", desc: "Live key-value, push on change" },
{ icon: "mem", label: "Memory", desc: "Full-text search, survives sessions" },
{ icon: "file", label: "Files", desc: "MinIO, per-peer access control" },
{ icon: "sql", label: "SQL database", desc: "Per-mesh Postgres schema" },
{ icon: "vec", label: "Vectors", desc: "Qdrant semantic search" },
{ icon: "graph", label: "Graph", desc: "Neo4j entity relationships" },
{ icon: "task", label: "Tasks", desc: "Create, claim, complete" },
{ icon: "ctx", label: "Context", desc: "Share session understanding" },
{ icon: "stream", label: "Streams", desc: "Real-time pub/sub feeds" },
{ icon: "sched", label: "Scheduled", desc: "Timed messages + reminders" },
] as const).map((cap) => (
<div
key={cap.label}
className="flex items-start gap-3 rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3"
>
<span
className="mt-0.5 shrink-0 text-[11px] font-medium text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{cap.icon}
</span>
<div>
<div
className="text-[13px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{cap.label}
</div>
<div
className="text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{cap.desc}
</div>
</div>
</div>
))}
</div>
<p
className="mt-6 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
43 MCP tools · 5 persistence backends · every call E2E encrypted
</p>
</Reveal>
{/* What it's NOT */}
<Reveal delay={2} className="mx-auto mt-24 max-w-3xl">
<div
@@ -457,10 +620,11 @@ export const WhatIsClaudemesh = () => {
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.
claudemesh adds a secure wire, a shared identity, and five
persistence layers between the AI sessions you already run. Your
Claudes stay specialized each knows its own repo. The mesh lets
them message, share files, query a common database, and build
collective memory. The human coordinates once, instead of N times.
</blockquote>
</Reveal>
</div>

View File

@@ -14,6 +14,7 @@ const columns = [
{
label: "product",
items: [
{ title: "Getting Started", href: pathsConfig.marketing.gettingStarted },
{ title: "Docs", href: "#docs" },
{ title: "Pricing", href: pathsConfig.marketing.pricing },
{ title: "Changelog", href: "#changelog" },
@@ -75,8 +76,8 @@ export const Footer = () => {
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.
Peer mesh for Claude Code. Messaging, files, databases, vectors,
graphs E2E encrypted. Every session, woven into one mesh.
</p>
<I18nControls />
<div className="mt-2 flex items-center gap-2.5">

View File

@@ -1,12 +1,13 @@
import Link from "next/link";
const NAV = [
{ label: "Docs", href: "#docs" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "#changelog" },
{ label: "Docs", href: "https://github.com/alezmad/claudemesh-cli#readme" },
{ label: "Blog", href: "/blog" },
{ label: "About", href: "/about" },
{ label: "Changelog", href: "/changelog" },
] as const;
const OSS_REPO_URL = "https://github.com/alezmad/claude-intercom";
const OSS_REPO_URL = "https://github.com/alezmad/claudemesh-cli";
export const Header = () => {
return (
@@ -66,8 +67,8 @@ export const Header = () => {
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"
aria-label="claudemesh-cli on GitHub"
title="claudemesh-cli · 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

View File

@@ -0,0 +1,138 @@
"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 {
PeerGraph,
type GraphPeer,
type GraphEdge,
} from "~/modules/mesh/peer-graph";
const POLL_INTERVAL_MS = 4000;
/* ------------------------------------------------------------------ */
/* Transform broker response into graph-friendly structures */
/* ------------------------------------------------------------------ */
const buildGraphData = (data: GetMyMeshStreamResponse) => {
// Count messages per sender
const countMap = new Map<string, number>();
for (const e of data.envelopes) {
countMap.set(e.senderMemberId, (countMap.get(e.senderMemberId) ?? 0) + 1);
}
const peers: GraphPeer[] = data.presences.map((p) => ({
id: p.memberId,
name: p.displayName ?? p.memberId.slice(0, 8),
status: p.status === "dnd" ? "dnd" : p.status,
messageCount: countMap.get(p.memberId) ?? 0,
}));
const edges: GraphEdge[] = data.envelopes.map((e) => ({
key: e.id,
from: e.senderMemberId,
to: e.targetSpec === "*" ? null : e.targetSpec,
priority: e.priority,
createdAt: new Date(e.createdAt),
}));
return { peers, edges };
};
/* ------------------------------------------------------------------ */
/* Panel component */
/* ------------------------------------------------------------------ */
export const PeerGraphPanel = ({ meshId }: { meshId: string }) => {
const { data, isFetching, dataUpdatedAt } = 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, edges } = useMemo(
() => (data ? buildGraphData(data) : { peers: [], edges: [] }),
[data],
);
const secondsAgo = dataUpdatedAt
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
: null;
return (
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header */}
<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)]">
peer graph
</span>
</div>
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
{peers.length} peers ·{" "}
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
</span>
</div>
{/* Graph area */}
<div className="relative aspect-square w-full min-h-[320px]">
<PeerGraph peers={peers} edges={edges} />
</div>
{/* Legend */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
idle
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
working
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#c46686]" />
dnd
</span>
<span className="mx-1 text-[var(--cm-border)]">|</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-3 bg-emerald-500" />
low
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-3 bg-[var(--cm-fg-secondary)]" />
next
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-3 bg-red-500" />
now
</span>
</div>
</div>
);
};

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