50 Commits

Author SHA1 Message Date
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
59 changed files with 14939 additions and 987 deletions

58
SPEC.md
View File

@@ -855,7 +855,63 @@ The broker:
---
## 13. Encryption
## 13. Claude Code Integration — How Push Delivery Works
Understanding how Claude Code processes channel notifications is critical for claudemesh reliability.
### The notification pipeline
```
MCP server (claudemesh-cli)
└─ server.notification("notifications/claude/channel", { content, meta })
└─ writes JSON-RPC to stdout
└─ Claude Code reads from MCP process stdout
└─ setNotificationHandler fires
└─ enqueue({ mode: "prompt", value: wrappedContent, origin: { kind: "channel" } })
└─ React useSyncExternalStore triggers re-render
└─ useQueueProcessor effect fires
└─ processQueueIfReady() → executeInput()
└─ Claude sees ← claudemesh: ...
```
### Key requirements (from Claude Code source)
1. **Feature gate**: `feature('KAIROS') || feature('KAIROS_CHANNELS')` must be true. `KAIROS_CHANNELS` is external (GrowthBook). `--dangerously-load-development-channels` sets `entry.dev = true` which bypasses the allowlist check but still requires the feature gate.
2. **OAuth auth required**: Channel notifications require `claude.ai` authentication (OAuth tokens). API key users are blocked. This means `claude login --for-claude-ai` must have been run.
3. **Server name must match**: The MCP server's declared name (`new Server({ name: "claudemesh" })`) must match the channel entry from `--dangerously-load-development-channels server:claudemesh`.
4. **Meta keys**: Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. No hyphens. All values must be strings.
5. **Capability declaration**: Server must declare `experimental: { "claude/channel": {} }` in capabilities.
6. **Queue processing is event-driven**: `enqueue()` triggers a React store update → `useEffect` fires → processes immediately. No polling needed on the Claude Code side. The 1s poll timer in claudemesh is for draining the WS push buffer into notifications — Claude Code handles the rest instantly.
### Priority gating on the broker
The broker holds `"next"` and `"low"` priority messages when the peer's status is `"working"`. Only `"now"` messages deliver immediately regardless of status. This is by design — but can cause perceived "push not working" when the hook reports `working` status.
```
Status: idle → delivers: now, next, low
Status: working → delivers: now only
Status: dnd → delivers: now only
```
If a peer appears to not receive messages, check their status in `list_peers`. A peer stuck in `"working"` (e.g., stale hook) will only receive `"now"` priority messages.
### Common issues
| Symptom | Likely cause |
|---------|-------------|
| Messages never arrive | Session started before CLI update — restart with `claudemesh launch` |
| Messages arrive with 5+ minute delay | Peer status stuck on `"working"``next` messages held until idle |
| `← claudemesh:` never appears in idle session | Feature gate `KAIROS_CHANNELS` not enabled, or not OAuth-authenticated |
| Messages arrive only on `check_messages` | Channel handler not registered — check `--dangerously-load-development-channels` flag |
---
## 14. Encryption
### Direct messages

View File

@@ -34,6 +34,7 @@ import {
mesh,
meshFile,
meshFileAccess,
meshFileKey,
meshContext,
meshMember as memberTable,
meshMemory,
@@ -395,6 +396,7 @@ export async function listPeersInMesh(
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
cwd: string;
connectedAt: Date;
}>
> {
@@ -408,6 +410,7 @@ export async function listPeersInMesh(
summary: presence.summary,
groups: presence.groups,
sessionId: presence.sessionId,
cwd: presence.cwd,
connectedAt: presence.connectedAt,
})
.from(presence)
@@ -427,6 +430,7 @@ export async function listPeersInMesh(
summary: r.summary,
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
sessionId: r.sessionId,
cwd: r.cwd,
connectedAt: r.connectedAt,
}));
}
@@ -717,6 +721,8 @@ export async function uploadFile(args: {
uploadedByMember?: string;
targetSpec?: string;
expiresAt?: Date;
encrypted?: boolean;
ownerPubkey?: string;
}): Promise<string> {
const [row] = await db
.insert(meshFile)
@@ -732,6 +738,8 @@ export async function uploadFile(args: {
uploadedByMember: args.uploadedByMember ?? null,
targetSpec: args.targetSpec ?? null,
expiresAt: args.expiresAt ?? null,
encrypted: args.encrypted ?? false,
ownerPubkey: args.ownerPubkey ?? null,
})
.returning({ id: meshFile.id });
if (!row) throw new Error("failed to insert file row");
@@ -755,6 +763,8 @@ export async function getFile(
uploadedByName: string | null;
targetSpec: string | null;
uploadedAt: Date;
encrypted: boolean;
ownerPubkey: string | null;
} | null> {
const [row] = await db
.select({
@@ -768,6 +778,8 @@ export async function getFile(
uploadedByName: meshFile.uploadedByName,
targetSpec: meshFile.targetSpec,
uploadedAt: meshFile.uploadedAt,
encrypted: meshFile.encrypted,
ownerPubkey: meshFile.ownerPubkey,
})
.from(meshFile)
.where(
@@ -782,6 +794,8 @@ export async function getFile(
return {
...row,
tags: (row.tags ?? []) as string[],
encrypted: row.encrypted,
ownerPubkey: row.ownerPubkey,
};
}
@@ -801,6 +815,7 @@ export async function listFiles(
uploadedBy: string;
uploadedAt: Date;
persistent: boolean;
encrypted: boolean;
}>
> {
const conditions = [
@@ -822,6 +837,7 @@ export async function listFiles(
uploadedByName: meshFile.uploadedByName,
uploadedAt: meshFile.uploadedAt,
persistent: meshFile.persistent,
encrypted: meshFile.encrypted,
})
.from(meshFile)
.where(and(...conditions))
@@ -835,6 +851,7 @@ export async function listFiles(
uploadedBy: r.uploadedByName ?? "unknown",
uploadedAt: r.uploadedAt,
persistent: r.persistent,
encrypted: r.encrypted,
}));
}
@@ -892,11 +909,62 @@ export async function deleteFile(
);
}
/** Insert encrypted key blobs for a newly uploaded E2E file. */
export async function insertFileKeys(
fileId: string,
keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>,
): Promise<void> {
if (keys.length === 0) return;
await db.insert(meshFileKey).values(
keys.map((k) => ({
fileId,
peerPubkey: k.peerPubkey,
sealedKey: k.sealedKey,
grantedByPubkey: k.grantedByPubkey ?? null,
})),
);
}
/** Get the sealed key for a specific peer, or null if not authorized. */
export async function getFileKey(
fileId: string,
peerPubkey: string,
): Promise<string | null> {
const [row] = await db
.select({ sealedKey: meshFileKey.sealedKey })
.from(meshFileKey)
.where(
and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)),
);
return row?.sealedKey ?? null;
}
/** Grant a peer access to an encrypted file (upsert their key blob). */
export async function grantFileKey(
fileId: string,
peerPubkey: string,
sealedKey: string,
grantedByPubkey: string,
): Promise<void> {
await db
.insert(meshFileKey)
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
.onConflictDoUpdate({
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
});
}
// --- Context sharing ---
/**
* Upsert a context snapshot for a peer. Each (meshId, presenceId) pair
* has at most one context row — repeated calls update it in place.
* Upsert a context snapshot for a peer. When `memberId` is provided the
* row is keyed on (meshId, memberId) — a stable identifier that survives
* reconnects. This prevents stale rows from accumulating every time a
* session reconnects with a fresh ephemeral presenceId.
*
* Falls back to (meshId, presenceId) lookup when memberId is absent
* (e.g. legacy callers or anonymous connections).
*/
export async function shareContext(
meshId: string,
@@ -906,24 +974,27 @@ export async function shareContext(
filesRead?: string[],
keyFindings?: string[],
tags?: string[],
memberId?: string,
): Promise<string> {
const now = new Date();
// Try to find existing context for this presence in this mesh.
// Build the WHERE clause: prefer stable memberId, fall back to presenceId.
const lookupWhere = memberId
? and(eq(meshContext.meshId, meshId), eq(meshContext.memberId, memberId))
: and(eq(meshContext.meshId, meshId), eq(meshContext.presenceId, presenceId));
const [existing] = await db
.select({ id: meshContext.id })
.from(meshContext)
.where(
and(
eq(meshContext.meshId, meshId),
eq(meshContext.presenceId, presenceId),
),
)
.where(lookupWhere)
.limit(1);
if (existing) {
await db
.update(meshContext)
.set({
// Keep presenceId current so it reflects the latest connection.
presenceId,
peerName: peerName ?? null,
summary,
filesRead: filesRead ?? [],
@@ -939,6 +1010,7 @@ export async function shareContext(
.insert(meshContext)
.values({
meshId,
memberId: memberId ?? null,
presenceId,
peerName: peerName ?? null,
summary,
@@ -1188,16 +1260,22 @@ export async function createStream(
name: string,
createdByName: string,
): Promise<string> {
const existing = await db
// Atomic upsert: INSERT ... ON CONFLICT DO NOTHING to avoid TOCTOU race
// when two callers concurrently attempt to create the same stream.
const [inserted] = await db
.insert(meshStream)
.values({ meshId, name, createdByName })
.onConflictDoNothing()
.returning({ id: meshStream.id });
if (inserted) return inserted.id;
// Row already existed — fetch the id.
const [existing] = await db
.select({ id: meshStream.id })
.from(meshStream)
.where(and(eq(meshStream.meshId, meshId), eq(meshStream.name, name)));
if (existing.length > 0) return existing[0]!.id;
const [row] = await db
.insert(meshStream)
.values({ meshId, name, createdByName })
.returning({ id: meshStream.id });
return row!.id;
return existing!.id;
}
/**
@@ -1302,11 +1380,28 @@ export async function drainForMember(
);
// Build group target matching: @all (broadcast alias) + @<groupname>
// for each group the peer belongs to.
// for each group the peer belongs to, expanded to all ancestor paths.
//
// Hierarchical routing (downward propagation):
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
// This lets leads send to a parent group and reach all sub-teams.
//
// Resolution happens at drain time (pull model) — no duplicates stored,
// no schema changes, fully backward-compatible.
const groupTargets = ["@all"];
if (memberGroups) {
const seen = new Set<string>();
for (const g of memberGroups) {
groupTargets.push(`@${g}`);
const parts = g.split("/");
// Add the group itself + every ancestor prefix.
for (let depth = parts.length; depth > 0; depth--) {
const ancestor = parts.slice(0, depth).join("/");
if (!seen.has(ancestor)) {
seen.add(ancestor);
groupTargets.push(`@${ancestor}`);
}
}
}
}
const groupTargetList = sql.raw(
@@ -1337,7 +1432,7 @@ export async function drainForMember(
AND delivered_at IS NULL
AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
)

View File

@@ -23,7 +23,7 @@ const envSchema = z.object({
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.coerce.boolean().default(false),
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"),

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,12 @@ export interface WSHelloMessage {
sessionId: string;
pid: number;
cwd: string;
/** Peer type: ai session, human user, or external connector. */
peerType?: "ai" | "human" | "connector";
/** Channel the peer connected from (e.g. "claude-code", "telegram", "slack", "web"). */
channel?: string;
/** AI model identifier (e.g. "opus-4", "sonnet-4"). */
model?: string;
/** Initial groups to join on connect. */
groups?: Array<{ name: string; role?: string }>;
/** ms epoch; broker rejects if outside ±60s of its own clock. */
@@ -86,6 +92,13 @@ export interface WSPushMessage {
nonce: string;
ciphertext: string;
createdAt: string;
/** Optional semantic tag — "reminder" when delivered by the scheduler,
* "system" for broker-originated topology events (peer join/leave). */
subtype?: "reminder" | "system";
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
event?: string;
/** Structured payload for the event. */
eventData?: Record<string, unknown>;
}
/** Client → broker: manual status override (dnd, forced idle). */
@@ -161,6 +174,7 @@ export interface WSAckMessage {
id: string; // echoes client-side correlation id
messageId: string;
queued: boolean;
_reqId?: string;
}
/** Broker → client: hello handshake acknowledgement. */
@@ -181,7 +195,12 @@ export interface WSPeersListMessage {
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
cwd?: string;
peerType?: "ai" | "human" | "connector";
channel?: string;
model?: string;
}>;
_reqId?: string;
}
/** Broker → client: a state key was changed by another peer. */
@@ -199,6 +218,7 @@ export interface WSStateResultMessage {
value: unknown;
updatedAt: string;
updatedBy: string;
_reqId?: string;
}
/** Broker → client: response to list_state. */
@@ -210,12 +230,14 @@ export interface WSStateListMessage {
updatedBy: string;
updatedAt: string;
}>;
_reqId?: string;
}
/** Broker → client: acknowledgement for a remember. */
export interface WSMemoryStoredMessage {
type: "memory_stored";
id: string;
_reqId?: string;
}
/** Broker → client: response to recall. */
@@ -228,6 +250,7 @@ export interface WSMemoryResultsMessage {
rememberedBy: string;
rememberedAt: string;
}>;
_reqId?: string;
}
// --- Vector storage messages ---
@@ -295,6 +318,13 @@ export interface WSMeshSchemaMessage {
// --- Vector/Graph response messages ---
/** Broker → client: confirmation that a vector point was stored. */
export interface WSVectorStoredMessage {
type: "vector_stored";
id: string;
_reqId?: string;
}
/** Broker → client: vector search results. */
export interface WSVectorResultsMessage {
type: "vector_results";
@@ -304,18 +334,21 @@ export interface WSVectorResultsMessage {
score: number;
metadata?: Record<string, unknown>;
}>;
_reqId?: string;
}
/** Broker → client: list of vector collections. */
export interface WSCollectionListMessage {
type: "collection_list";
collections: string[];
_reqId?: string;
}
/** Broker → client: graph query results. */
export interface WSGraphResultMessage {
type: "graph_result";
records: Array<Record<string, unknown>>;
_reqId?: string;
}
/** Broker → client: mesh SQL query results. */
@@ -324,6 +357,7 @@ export interface WSMeshQueryResultMessage {
columns: string[];
rows: Array<Record<string, unknown>>;
rowCount: number;
_reqId?: string;
}
/** Broker → client: mesh schema introspection results. */
@@ -333,6 +367,7 @@ export interface WSMeshSchemaResultMessage {
name: string;
columns: Array<{ name: string; type: string; nullable: boolean }>;
}>;
_reqId?: string;
}
/** Client → broker: get full mesh overview. */
@@ -355,6 +390,7 @@ export interface WSMeshInfoResultMessage {
collections: string[];
yourName: string;
yourGroups: Array<{ name: string; role?: string }>;
_reqId?: string;
}
/** Client → broker: check delivery status of a message. */
@@ -375,6 +411,7 @@ export interface WSMessageStatusResultMessage {
pubkey: string;
status: "delivered" | "held" | "disconnected";
}>;
_reqId?: string;
}
// --- File sharing messages ---
@@ -404,12 +441,23 @@ export interface WSDeleteFileMessage {
fileId: string;
}
/** Client → broker: grant a peer access to an encrypted file. */
export interface WSGrantFileAccessMessage {
type: "grant_file_access";
fileId: string;
peerPubkey: string;
sealedKey: string;
}
/** Broker → client: presigned URL for downloading a file. */
export interface WSFileUrlMessage {
type: "file_url";
fileId: string;
url: string;
name: string;
encrypted?: boolean;
sealedKey?: string;
_reqId?: string;
}
/** Broker → client: list of files in the mesh. */
@@ -423,7 +471,17 @@ export interface WSFileListMessage {
uploadedBy: string;
uploadedAt: string;
persistent: boolean;
encrypted: boolean;
}>;
_reqId?: string;
}
/** Broker → client: acknowledgement for grant_file_access. */
export interface WSGrantFileAccessOkMessage {
type: "grant_file_access_ok";
fileId: string;
peerPubkey: string;
_reqId?: string;
}
/** Broker → client: access log for a file. */
@@ -434,6 +492,7 @@ export interface WSFileStatusResultMessage {
peerName: string;
accessedAt: string;
}>;
_reqId?: string;
}
// --- Context sharing messages ---
@@ -475,6 +534,7 @@ export interface WSContextResultsMessage {
tags: string[];
updatedAt: string;
}>;
_reqId?: string;
}
/** Broker → client: response to list_contexts. */
@@ -486,6 +546,7 @@ export interface WSContextListMessage {
tags: string[];
updatedAt: string;
}>;
_reqId?: string;
}
// --- Task messages ---
@@ -523,6 +584,7 @@ export interface WSListTasksMessage {
export interface WSTaskCreatedMessage {
type: "task_created";
id: string;
_reqId?: string;
}
/** Broker → client: response to list_tasks, claim_task, complete_task. */
@@ -539,6 +601,7 @@ export interface WSTaskListMessage {
tags: string[];
createdAt: string;
}>;
_reqId?: string;
}
// --- Stream messages ---
@@ -578,6 +641,7 @@ export interface WSStreamCreatedMessage {
type: "stream_created";
id: string;
name: string;
_reqId?: string;
}
/** Broker → client: real-time data pushed from a stream. */
@@ -588,6 +652,13 @@ export interface WSStreamDataMessage {
publishedBy: string;
}
/** Broker → client: confirmation that a stream subscription was registered. */
export interface WSSubscribedMessage {
type: "subscribed";
stream: string;
_reqId?: string;
}
/** Broker → client: response to list_streams. */
export interface WSStreamListMessage {
type: "stream_list";
@@ -598,6 +669,7 @@ export interface WSStreamListMessage {
createdAt: string;
subscriberCount: number;
}>;
_reqId?: string;
}
/** Broker → client: structured error. */
@@ -606,6 +678,73 @@ export interface WSErrorMessage {
code: string;
message: string;
id?: string;
_reqId?: string;
}
// --- Scheduled messages ---
/** Client → broker: schedule a message for future delivery. */
export interface WSScheduleMessage {
type: "schedule";
to: string;
message: string;
/** Unix timestamp (ms) when to deliver. Ignored for cron schedules. */
deliverAt: number;
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
subtype?: "reminder";
/** Standard 5-field cron expression for recurring delivery. */
cron?: string;
/** Whether this is a recurring schedule. Implied true when `cron` is set. */
recurring?: boolean;
_reqId?: string;
}
/** Client → broker: list pending scheduled messages for this member. */
export interface WSListScheduledMessage {
type: "list_scheduled";
_reqId?: string;
}
/** Client → broker: cancel a scheduled message by id. */
export interface WSCancelScheduledMessage {
type: "cancel_scheduled";
scheduledId: string;
_reqId?: string;
}
/** Broker → client: acknowledgement for schedule, carries the assigned id. */
export interface WSScheduledAckMessage {
type: "scheduled_ack";
scheduledId: string;
deliverAt: number;
/** Present for cron schedules — echoes the expression. */
cron?: string;
_reqId?: string;
}
/** Broker → client: list of pending scheduled messages. */
export interface WSScheduledListMessage {
type: "scheduled_list";
messages: Array<{
id: string;
to: string;
message: string;
deliverAt: number;
createdAt: number;
/** Present for cron/recurring entries. */
cron?: string;
/** Number of times the cron entry has fired so far. */
firedCount?: number;
}>;
_reqId?: string;
}
/** Broker → client: cancel confirmation. */
export interface WSCancelScheduledAckMessage {
type: "cancel_scheduled_ack";
scheduledId: string;
ok: boolean;
_reqId?: string;
}
export type WSClientMessage =
@@ -627,6 +766,7 @@ export type WSClientMessage =
| WSListFilesMessage
| WSFileStatusMessage
| WSDeleteFileMessage
| WSGrantFileAccessMessage
| WSShareContextMessage
| WSGetContextMessage
| WSListContextsMessage
@@ -648,7 +788,10 @@ export type WSClientMessage =
| WSSubscribeMessage
| WSUnsubscribeMessage
| WSListStreamsMessage
| WSMeshInfoMessage;
| WSMeshInfoMessage
| WSScheduleMessage
| WSListScheduledMessage
| WSCancelScheduledMessage;
export type WSServerMessage =
| WSHelloAckMessage
@@ -664,11 +807,13 @@ export type WSServerMessage =
| WSFileUrlMessage
| WSFileListMessage
| WSFileStatusResultMessage
| WSGrantFileAccessOkMessage
| WSContextSharedMessage
| WSContextResultsMessage
| WSContextListMessage
| WSTaskCreatedMessage
| WSTaskListMessage
| WSVectorStoredMessage
| WSVectorResultsMessage
| WSCollectionListMessage
| WSGraphResultMessage
@@ -676,6 +821,10 @@ export type WSServerMessage =
| WSMeshSchemaResultMessage
| WSStreamCreatedMessage
| WSStreamDataMessage
| WSSubscribedMessage
| WSStreamListMessage
| WSMeshInfoResultMessage
| WSScheduledAckMessage
| WSScheduledListMessage
| WSCancelScheduledAckMessage
| WSErrorMessage;

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "0.5.1",
"version": "0.6.9",
"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,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,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

@@ -212,6 +212,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 +407,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 {
@@ -375,6 +481,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

@@ -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
@@ -18,73 +21,17 @@ import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
// --- Arg parsing ---
interface LaunchArgs {
name: string | null;
role: string | null;
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
joinLink: string | null;
meshSlug: string | null;
messageMode: "push" | "inbox" | "off" | null;
quiet: boolean;
skipPermConfirm: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
role: null,
groups: null,
joinLink: null,
meshSlug: null,
messageMode: null,
quiet: false,
skipPermConfirm: false,
claudeArgs: [],
};
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--name" && i + 1 < argv.length) {
result.name = argv[++i]!;
} else if (arg.startsWith("--name=")) {
result.name = arg.slice("--name=".length);
} else if (arg === "--role" && i + 1 < argv.length) {
result.role = argv[++i]!;
} else if (arg.startsWith("--role=")) {
result.role = arg.slice("--role=".length);
} else if (arg === "--groups" && i + 1 < argv.length) {
result.groups = argv[++i]!;
} else if (arg.startsWith("--groups=")) {
result.groups = arg.slice("--groups=".length);
} else if (arg === "--join" && i + 1 < argv.length) {
result.joinLink = argv[++i]!;
} else if (arg.startsWith("--join=")) {
result.joinLink = arg.slice("--join=".length);
} else if (arg === "--mesh" && i + 1 < argv.length) {
result.meshSlug = argv[++i]!;
} else if (arg.startsWith("--mesh=")) {
result.meshSlug = arg.slice("--mesh=".length);
} else if (arg === "--inbox") {
result.messageMode = "inbox";
} else if (arg === "--no-messages") {
result.messageMode = "off";
} else if (arg === "--quiet") {
result.quiet = true;
} else if (arg === "-y" || arg === "--yes") {
result.skipPermConfirm = true;
} else if (arg === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
// 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;
yes?: boolean;
quiet?: boolean;
}
// --- Interactive mesh picker ---
@@ -151,12 +98,12 @@ async function confirmPermissions(): Promise<void> {
console.log(yellow(bold(" Autonomous mode")));
console.log("");
console.log(" Claude will send and receive peer messages without asking");
console.log(" you first. Peers exchange text only — no file access,");
console.log(" no tool calls, no code execution.");
console.log(" 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(" Same as: claude --dangerously-skip-permissions"));
console.log(dim(" Skip this prompt: claudemesh launch -y"));
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 });
@@ -206,8 +153,26 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
// --- 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,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first.
if (args.joinLink) {
@@ -318,6 +283,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
@@ -347,10 +313,15 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
}
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.
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
"--dangerously-skip-permissions",
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered,
];
@@ -362,6 +333,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(role ? { CLAUDEMESH_ROLE: role } : {}),
},
});

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,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,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,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,268 @@ 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 { 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,
},
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);
},
}),
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); },
}),
},
run() {
runWelcome();
},
});
runMain(main);

View File

@@ -123,13 +123,15 @@ function decryptFailedWarning(senderPubkey: string): string {
function formatPush(p: InboundPush, meshSlug: string): string {
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
const tag = p.subtype === "reminder" ? " [REMINDER]" : "";
return `[${meshSlug}]${tag} from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
}
export async function startMcpServer(): Promise<void> {
const config = loadConfig();
const myName = config.displayName ?? "unnamed";
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push";
@@ -141,11 +143,13 @@ export async function startMcpServer(): Promise<void> {
tools: {},
},
instructions: `## Identity
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
## Tools
| Tool | Description |
|------|-------------|
@@ -187,6 +191,9 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
| claim_task(id) | Claim an unclaimed task. |
| complete_task(id, result?) | Mark task done with optional result. |
| list_tasks(status?, assignee?) | List tasks filtered by status/assignee. |
| schedule_reminder(message, in_seconds?, deliver_at?, to?) | Schedule a reminder to yourself (no \`to\`) or a delayed message to a peer/group. Delivered as a push with \`subtype: reminder\` in the channel meta. |
| list_scheduled() | List pending scheduled reminders and messages. |
| cancel_scheduled(id) | Cancel a pending scheduled item. |
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
@@ -315,7 +322,13 @@ Your message mode is "${messageMode}".
const peerLines = peers.map((p) => {
const summary = p.summary ? ` — "${p.summary}"` : "";
const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr} (${p.pubkey.slice(0, 12)}…)${summary}`;
const meta: string[] = [];
if (p.peerType) meta.push(`type:${p.peerType}`);
if (p.channel) meta.push(`channel:${p.channel}`);
if (p.model) meta.push(`model:${p.model}`);
const metaStr = meta.length ? ` {${meta.join(", ")}}` : "";
const cwdStr = p.cwd ? ` cwd:${p.cwd}` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr}${metaStr} (${p.pubkey.slice(0, 12)}…)${cwdStr}${summary}`;
});
sections.push(`${header}\n${peerLines.join("\n")}`);
}
@@ -326,9 +339,15 @@ Your message mode is "${messageMode}".
case "message_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("message_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("message_status: not connected", true);
const result = await client.messageStatus(id);
const clients = allClients();
if (!clients.length) return text("message_status: not connected", true);
// Try each connected mesh client — we don't know which mesh the
// messageId belongs to, so query all and return the first hit.
let result = null;
for (const c of clients) {
result = await c.messageStatus(id);
if (result) break;
}
if (!result) return text(`Message ${id} not found or timed out.`);
const recipientLines = result.recipients.map(
(r: { name: string; pubkey: string; status: string }) =>
@@ -437,19 +456,165 @@ Your message mode is "${messageMode}".
return text(`Forgotten: ${id}`);
}
// --- Scheduled messages ---
case "schedule_reminder": {
const sArgs = (args ?? {}) as {
message?: string;
to?: string;
deliver_at?: number;
in_seconds?: number;
cron?: string;
};
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
const isCron = !!sArgs.cron;
let deliverAt: number;
if (isCron) {
// For cron, deliverAt is ignored by the broker — set to 0
deliverAt = 0;
} else if (sArgs.deliver_at) {
deliverAt = Number(sArgs.deliver_at);
} else if (sArgs.in_seconds) {
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
} else {
return text("schedule_reminder: provide `deliver_at` (ms timestamp), `in_seconds`, or `cron` expression", true);
}
const isSelf = !sArgs.to;
let targetSpec: string;
if (isSelf) {
// Self-reminder: target own session pubkey
targetSpec = client.getSessionPubkey() ?? "*";
} else {
const to = sArgs.to!;
// Resolve display name → pubkey if not a raw spec
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
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(", ");
return text(`schedule_reminder: peer "${to}" not found. Online: ${names || "(none)"}`, true);
}
targetSpec = match.pubkey;
} else {
targetSpec = to;
}
}
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true, sArgs.cron);
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
if (isCron) {
const nextFire = new Date(result.deliverAt).toISOString();
return text(
isSelf
? `Recurring self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" — cron: ${sArgs.cron}, next fire: ${nextFire}`
: `Recurring reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) — cron: ${sArgs.cron}, next fire: ${nextFire}`,
);
}
const when = new Date(result.deliverAt).toISOString();
return text(
isSelf
? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
: `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
);
}
case "list_scheduled": {
const scheduled = await client.listScheduled();
if (scheduled.length === 0) return text("No pending scheduled messages.");
const lines = scheduled.map((m) =>
`- [${m.id.slice(0, 8)}] → ${m.to === client.getSessionPubkey() ? "self (reminder)" : m.to} at ${new Date(m.deliverAt).toISOString()}: "${m.message.slice(0, 60)}${m.message.length > 60 ? "…" : ""}"`,
);
return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`);
}
case "cancel_scheduled": {
const { id: schedId } = (args ?? {}) as { id?: string };
if (!schedId) return text("cancel_scheduled: `id` required", true);
const ok = await client.cancelScheduled(schedId);
return text(ok ? `Cancelled: ${schedId}` : `Not found or already fired: ${schedId}`, !ok);
}
// --- Files ---
case "share_file": {
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] };
const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string };
if (!filePath) return text("share_file: `path` required", true);
const { existsSync } = await import("node:fs");
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0];
if (!client) return text("share_file: not connected", true);
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true,
});
if (!fileId) return text("share_file: upload failed", true);
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
// If 'to' specified, do E2E encryption
if (fileTo) {
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join, basename } = await import("node:path");
// Resolve target peer pubkey
const peers = await client.listPeers();
const targetPeer = peers.find(p => p.pubkey === fileTo || p.displayName === fileTo);
if (!targetPeer) {
return text(`share_file: peer not found: ${fileTo}`, true);
}
// Read and encrypt file
const plaintext = readFileSync(filePath);
const { ciphertext, nonce, key } = await encryptFile(new Uint8Array(plaintext));
// Seal Kf for target peer
const sealedForTarget = await sealKeyForPeer(key, targetPeer.pubkey);
// Seal Kf for ourselves (owner)
const myPubkey = client.getSessionPubkey();
const sealedForSelf = myPubkey ? await sealKeyForPeer(key, myPubkey) : null;
const fileKeys = [
{ peerPubkey: targetPeer.pubkey, sealedKey: sealedForTarget },
...(sealedForSelf && myPubkey ? [{ peerPubkey: myPubkey, sealedKey: sealedForSelf }] : []),
];
// Build combined buffer: nonce (24 bytes) + ciphertext
const { ensureSodium } = await import("../crypto/keypair");
const sodium = await ensureSodium();
const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL);
const combined = new Uint8Array(nonceBytes.length + ciphertext.length);
combined.set(nonceBytes, 0);
combined.set(ciphertext, nonceBytes.length);
const baseName = fileName ?? basename(filePath);
const tmpDir = mkdtempSync(join(tmpdir(), "cm-"));
const tmpPath = join(tmpDir, baseName);
writeFileSync(tmpPath, combined);
try {
const fileId = await client.uploadFile(tmpPath, client.meshId, client.meshSlug, {
name: baseName,
tags,
persistent: true,
encrypted: true,
ownerPubkey: myPubkey ?? undefined,
fileKeys,
});
return text(`Shared (E2E encrypted): ${baseName}${targetPeer.displayName} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
} finally {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
try { rmdirSync(tmpDir); } catch { /* ignore */ }
}
}
// Plain (unencrypted) upload — existing code
try {
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true,
});
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
}
}
case "get_file": {
@@ -459,6 +624,43 @@ Your message mode is "${messageMode}".
if (!client) return text("get_file: not connected", true);
const result = await client.getFile(id);
if (!result) return text(`get_file: file ${id} not found`, true);
if (result.encrypted) {
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) {
return text("get_file: no session keypair — cannot decrypt", true);
}
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("get_file: failed to open sealed key", true);
// Download file bytes from presigned URL
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
const buf = new Uint8Array(await resp.arrayBuffer());
// Wire format: first 24 bytes = nonce, rest = ciphertext
const sodium = await ensureSodium();
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
const ciphertext = buf.slice(NONCE_BYTES);
const plaintext = await decryptFile(ciphertext, nonce, kf);
if (!plaintext) return text("get_file: decryption failed", true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
mkdirSync(dirname(save_to), { recursive: true });
writeFileSync(save_to, plaintext);
return text(`Downloaded and decrypted: ${result.name}${save_to}`);
}
// Unencrypted — existing download logic
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
const { writeFileSync, mkdirSync } = await import("node:fs");
@@ -707,6 +909,86 @@ Your message mode is "${messageMode}".
return text(lines.join("\n"));
}
case "ping_mesh": {
const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] };
const toTest = (pingPriorities ?? ["now", "next"]) as Priority[];
const client = allClients()[0];
if (!client) return text("ping_mesh: not connected", true);
const results: string[] = [];
// Diagnostics: connection state
results.push(`WS status: ${client.status}`);
results.push(`Mesh: ${client.meshSlug}`);
// Check own peer status (explains priority gating)
const peers = await client.listPeers();
const selfPeer = peers.find(p => p.displayName === myName);
results.push(`Your status: ${selfPeer?.status ?? "not found in peer list"}`);
results.push(`Peers online: ${peers.length}`);
results.push(`Push buffer: ${client.pushHistory.length} buffered`);
// Test send→ack latency per priority (doesn't need round-trip)
for (const prio of toTest) {
const sendTime = Date.now();
// Send to a peer if one exists, otherwise broadcast
const target = peers.find(p => p.displayName !== myName);
const sendResult = await client.send(
target?.pubkey ?? "*",
`__ping__ ${prio} from ${myName} at ${new Date().toISOString()}`,
prio,
);
const ackTime = Date.now();
if (!sendResult.ok) {
results.push(`[${prio}] SEND FAILED: ${sendResult.error}`);
} else {
results.push(`[${prio}] send→ack: ${ackTime - sendTime}ms (msgId: ${sendResult.messageId?.slice(0, 12)})`);
if (prio !== "now" && selfPeer?.status === "working") {
results.push(` ⚠ peer status is "working" — broker holds "${prio}" until idle`);
}
}
}
// Check if notification pipeline works
results.push("");
results.push("Pipeline check:");
results.push(` onPush handlers: active`);
results.push(` messageMode: ${messageMode}`);
results.push(` server.notification: ${messageMode === "off" ? "disabled (mode=off)" : "enabled"}`);
return text(results.join("\n"));
}
case "grant_file_access": {
const { fileId, to: grantTo } = (args ?? {}) as { fileId?: string; to?: string };
if (!fileId || !grantTo) return text("grant_file_access: `fileId` and `to` required", true);
const client = allClients()[0];
if (!client) return text("grant_file_access: not connected", true);
const peers = await client.listPeers();
const targetPeer = peers.find(p => p.pubkey === grantTo || p.displayName === grantTo);
if (!targetPeer) return text(`grant_file_access: peer not found: ${grantTo}`, true);
const result = await client.getFile(fileId);
if (!result) return text("grant_file_access: file not found", true);
if (!result.encrypted) return text("grant_file_access: file is not encrypted", true);
if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true);
const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true);
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("grant_file_access: cannot decrypt your own key", true);
const sealedForPeer = await sealKeyForPeer(kf, targetPeer.pubkey);
const ok = await client.grantFileAccess(fileId, targetPeer.pubkey, sealedForPeer);
if (!ok) return text("grant_file_access: broker did not confirm", true);
return text(`Access granted: ${targetPeer.displayName} can now download file ${fileId}`);
}
default:
return text(`Unknown tool: ${name}`, true);
}
@@ -722,19 +1004,53 @@ Your message mode is "${messageMode}".
// any mesh's broker connection becomes a <channel source="claudemesh">
// system reminder injected into Claude Code's context.
for (const client of allClients()) {
// Event-driven push: WS onPush fires immediately when a message arrives.
// Claude Code's setNotificationHandler → enqueue → React useEffect pipeline
// processes notifications instantly (no polling needed on Claude's side).
// The old poll-based approach was an overcorrection — Claude Code source
// confirms event-driven notification processing.
client.onPush(async (msg) => {
// In "off" mode, silently skip notification — messages are still
// buffered in pushBuffer and accessible via check_messages.
if (messageMode === "off") return;
// System events (peer join/leave) — always push, regardless of mode.
if (msg.subtype === "system" && msg.event) {
const eventName = msg.event;
const data = msg.eventData ?? {};
let content: string;
if (eventName === "peer_joined") {
content = `[system] Peer "${data.name ?? "unknown"}" joined the mesh`;
} else if (eventName === "peer_left") {
content = `[system] Peer "${data.name ?? "unknown"}" left the mesh`;
} else {
content = `[system] ${eventName}: ${JSON.stringify(data)}`;
}
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content,
meta: {
kind: "system",
event: eventName,
mesh_slug: client.meshSlug,
mesh_id: client.meshId,
...(Object.keys(data).length > 0 ? { eventData: data } : {}),
},
},
});
process.stderr.write(`[claudemesh] system: ${content}\n`);
} catch (pushErr) {
process.stderr.write(`[claudemesh] system push FAILED: ${pushErr}\n`);
}
return;
}
const fromPubkey = msg.senderPubkey || "";
// Resolve sender's display name from the cached peer list.
const fromName = fromPubkey
? await resolvePeerName(client, fromPubkey)
: "unknown";
if (messageMode === "inbox") {
// Count-only notification, no content
try {
await server.notification({
method: "notifications/claude/channel",
@@ -747,7 +1063,7 @@ Your message mode is "${messageMode}".
return;
}
// push mode — full content notification
// push mode — full content
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
try {
await server.notification({
@@ -763,11 +1079,13 @@ Your message mode is "${messageMode}".
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
...(msg.subtype ? { subtype: msg.subtype } : {}),
},
},
});
} catch {
/* channel push is best-effort; check_messages is the fallback */
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`);
} catch (pushErr) {
process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`);
}
});
@@ -804,7 +1122,42 @@ Your message mode is "${messageMode}".
});
}
// Welcome notification: give Claude immediate context on connect.
// Triggers Claude to call mesh_info/list_peers without user input.
setTimeout(async () => {
const client = allClients()[0];
if (!client || client.status !== "open") return;
try {
const peers = await client.listPeers();
const peerNames = peers
.filter(p => p.displayName !== myName)
.map(p => p.displayName)
.join(", ") || "none";
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[system] Connected as ${myName} to mesh ${client.meshSlug}. ${peers.length} peer(s) online: ${peerNames}. Call mesh_info for full details or set_summary to announce yourself.`,
meta: { kind: "welcome", mesh_slug: client.meshSlug },
},
});
} catch { /* best effort */ }
}, 3_000); // 3s delay: let WS connect + hello_ack complete first
// Event loop keepalive: Node.js stdout to a pipe is buffered. Without
// periodic event loop activity, stdout.write() from WS callbacks may not
// flush until the next I/O event. This 1s interval keeps the event loop
// ticking so channel notifications flush promptly — same pattern that made
// claude-intercom's push delivery reliable (its 1s HTTP poll had this
// effect as a side effect). The interval does nothing except prevent the
// event loop from settling.
const keepalive = setInterval(() => {
// Intentionally empty — the interval itself keeps the event loop active.
// Do NOT call .unref() — that would defeat the purpose.
}, 1_000);
void keepalive; // suppress unused warning
const shutdown = (): void => {
clearInterval(keepalive);
stopAll();
process.exit(0);
};

View File

@@ -203,7 +203,7 @@ export const TOOLS: Tool[] = [
{
name: "share_file",
description:
"Share a persistent file with the mesh. All current and future peers can access it.",
"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: {
@@ -217,6 +217,10 @@ export const TOOLS: Tool[] = [
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"],
},
@@ -269,6 +273,18 @@ export const TOOLS: Tool[] = [
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 ---
{
@@ -548,6 +564,43 @@ export const TOOLS: Tool[] = [
},
},
// --- 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",
@@ -555,4 +608,21 @@ export const TOOLS: Tool[] = [
"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: {} },
},
// --- 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\"])",
},
},
},
},
];

View File

@@ -37,6 +37,7 @@ 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";
}
@@ -54,7 +55,7 @@ export function loadConfig(): Config {
if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] };
}
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode };
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode };
} 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."
}

View File

@@ -34,6 +34,10 @@ export interface PeerInfo {
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
cwd?: string;
peerType?: "ai" | "human" | "connector";
channel?: string;
model?: string;
}
export interface InboundPush {
@@ -51,6 +55,13 @@ export interface InboundPush {
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
* (plaintext for now). */
kind: "direct" | "broadcast" | "channel" | "unknown";
/** Optional semantic tag — "reminder" when fired by the scheduler,
* "system" for broker-originated topology events. */
subtype?: "reminder" | "system";
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
event?: string;
/** Structured payload for the event. */
eventData?: Record<string, unknown>;
}
type PushHandler = (msg: InboundPush) => void;
@@ -75,14 +86,15 @@ export class BrokerClient {
private outbound: Array<() => void> = []; // closures that send once ws is open
private pushHandlers = new Set<PushHandler>();
private pushBuffer: InboundPush[] = [];
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
private stateResolvers: Array<(result: { key: string; value: unknown; updatedBy: string; updatedAt: string } | null) => void> = [];
private stateListResolvers: Array<(entries: Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) => void> = [];
private memoryStoreResolvers: Array<(id: string | null) => void> = [];
private memoryRecallResolvers: Array<(memories: Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) => void> = [];
private listPeersResolvers = new Map<string, { resolve: (peers: PeerInfo[]) => void; timer: NodeJS.Timeout }>();
private stateResolvers = new Map<string, { resolve: (result: { key: string; value: unknown; updatedBy: string; updatedAt: string } | null) => void; timer: NodeJS.Timeout }>();
private stateListResolvers = new Map<string, { resolve: (entries: Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) => void; timer: NodeJS.Timeout }>();
private memoryStoreResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
private memoryRecallResolvers = new Map<string, { resolve: (memories: Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) => void; timer: NodeJS.Timeout }>();
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private grantFileAccessResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
private closed = false;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
@@ -110,6 +122,15 @@ export class BrokerClient {
return this.pushBuffer;
}
/** Session public key hex (null before first connection). */
getSessionPubkey(): string | null { return this.sessionPubkey; }
/** Session secret key hex (null before first connection). */
getSessionSecretKey(): string | null { return this.sessionSecretKey; }
private makeReqId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
/** Open WS, send hello, resolve when hello_ack received. */
async connect(): Promise<void> {
if (this.closed) throw new Error("client is closed");
@@ -145,6 +166,9 @@ export class BrokerClient {
sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
peerType: "ai" as const,
channel: "claude-code",
model: process.env.CLAUDE_MODEL || undefined,
timestamp,
signature,
}),
@@ -299,16 +323,11 @@ export class BrokerClient {
async listPeers(): Promise<PeerInfo[]> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.listPeersResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_peers" }));
// Timeout after 5s — return empty list rather than hang.
setTimeout(() => {
const idx = this.listPeersResolvers.indexOf(resolve);
if (idx !== -1) {
this.listPeersResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
const reqId = this.makeReqId();
this.listPeersResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.listPeersResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_peers", _reqId: reqId }));
});
}
@@ -342,15 +361,11 @@ export class BrokerClient {
async getState(key: string): Promise<{ key: string; value: unknown; updatedBy: string; updatedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.stateResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_state", key }));
setTimeout(() => {
const idx = this.stateResolvers.indexOf(resolve);
if (idx !== -1) {
this.stateResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
const reqId = this.makeReqId();
this.stateResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.stateResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId }));
});
}
@@ -358,15 +373,11 @@ export class BrokerClient {
async listState(): Promise<Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.stateListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_state" }));
setTimeout(() => {
const idx = this.stateListResolvers.indexOf(resolve);
if (idx !== -1) {
this.stateListResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
const reqId = this.makeReqId();
this.stateListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.stateListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_state", _reqId: reqId }));
});
}
@@ -376,15 +387,11 @@ export class BrokerClient {
async remember(content: string, tags?: string[]): Promise<string | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.memoryStoreResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "remember", content, tags }));
setTimeout(() => {
const idx = this.memoryStoreResolvers.indexOf(resolve);
if (idx !== -1) {
this.memoryStoreResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
const reqId = this.makeReqId();
this.memoryStoreResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.memoryStoreResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "remember", content, tags, _reqId: reqId }));
});
}
@@ -392,15 +399,11 @@ export class BrokerClient {
async recall(query: string): Promise<Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.memoryRecallResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "recall", query }));
setTimeout(() => {
const idx = this.memoryRecallResolvers.indexOf(resolve);
if (idx !== -1) {
this.memoryRecallResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
const reqId = this.makeReqId();
this.memoryRecallResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.memoryRecallResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "recall", query, _reqId: reqId }));
});
}
@@ -410,52 +413,102 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "forget", memoryId }));
}
// --- Scheduled messages ---
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt, cron? } or null on timeout. */
async scheduleMessage(
to: string,
message: string,
deliverAt: number,
isReminder = false,
cron?: string,
): Promise<{ scheduledId: string; deliverAt: number; cron?: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.scheduledAckResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.scheduledAckResolvers.delete(reqId)) resolve(null);
}, 8_000) });
this.ws!.send(JSON.stringify({
type: "schedule",
to,
message,
deliverAt,
...(isReminder ? { subtype: "reminder" } : {}),
...(cron ? { cron, recurring: true } : {}),
_reqId: reqId,
}));
});
}
/** List all pending scheduled messages for this session. */
async listScheduled(): Promise<Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.scheduledListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.scheduledListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_scheduled", _reqId: reqId }));
});
}
/** Cancel a scheduled message by id. Returns true if found and cancelled. */
async cancelScheduled(scheduledId: string): Promise<boolean> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.cancelScheduledResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.cancelScheduledResolvers.delete(reqId)) resolve(false);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "cancel_scheduled", scheduledId, _reqId: reqId }));
});
}
/** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = [];
private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = [];
private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
private vectorStoredResolvers: Array<(id: string | null) => void> = [];
private vectorResultsResolvers: Array<(results: Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) => void> = [];
private collectionListResolvers: Array<(collections: string[]) => void> = [];
private graphResultResolvers: Array<(rows: Array<Record<string, unknown>>) => void> = [];
private contextListResolvers: Array<(contexts: Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) => void> = [];
private contextResultsResolvers: Array<(contexts: Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) => void> = [];
private taskCreatedResolvers: Array<(id: string | null) => void> = [];
private taskListResolvers: Array<(tasks: Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) => void> = [];
private meshQueryResolvers: Array<(result: { columns: string[]; rows: Array<Record<string, unknown>>; rowCount: number } | null) => void> = [];
private meshSchemaResolvers: Array<(tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) => void> = [];
private streamCreatedResolvers: Array<(id: string | null) => void> = [];
private streamListResolvers: Array<(streams: Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) => void> = [];
private messageStatusResolvers = new Map<string, { resolve: (result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void; timer: NodeJS.Timeout }>();
private fileUrlResolvers = new Map<string, { resolve: (result: { url: string; name: string; encrypted?: boolean; sealedKey?: string } | null) => void; timer: NodeJS.Timeout }>();
private fileListResolvers = new Map<string, { resolve: (files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void; timer: NodeJS.Timeout }>();
private fileStatusResolvers = new Map<string, { resolve: (accesses: Array<{ peerName: string; accessedAt: string }>) => void; timer: NodeJS.Timeout }>();
private vectorStoredResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
private vectorResultsResolvers = new Map<string, { resolve: (results: Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) => void; timer: NodeJS.Timeout }>();
private collectionListResolvers = new Map<string, { resolve: (collections: string[]) => void; timer: NodeJS.Timeout }>();
private graphResultResolvers = new Map<string, { resolve: (rows: Array<Record<string, unknown>>) => void; timer: NodeJS.Timeout }>();
private contextListResolvers = new Map<string, { resolve: (contexts: Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) => void; timer: NodeJS.Timeout }>();
private contextResultsResolvers = new Map<string, { resolve: (contexts: Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) => void; timer: NodeJS.Timeout }>();
private taskCreatedResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
private taskListResolvers = new Map<string, { resolve: (tasks: Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) => void; timer: NodeJS.Timeout }>();
private meshQueryResolvers = new Map<string, { resolve: (result: { columns: string[]; rows: Array<Record<string, unknown>>; rowCount: number } | null) => void; timer: NodeJS.Timeout }>();
private meshSchemaResolvers = new Map<string, { resolve: (tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) => void; timer: NodeJS.Timeout }>();
private streamCreatedResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
private streamListResolvers = new Map<string, { resolve: (streams: Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) => void; timer: NodeJS.Timeout }>();
private streamDataHandlers = new Set<(data: { stream: string; data: unknown; publishedBy: string }) => void>();
private scheduledAckResolvers = new Map<string, { resolve: (result: { scheduledId: string; deliverAt: number } | null) => void; timer: NodeJS.Timeout }>();
private scheduledListResolvers = new Map<string, { resolve: (messages: Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) => void; timer: NodeJS.Timeout }>();
private cancelScheduledResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.messageStatusResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "message_status", messageId }));
setTimeout(() => {
const idx = this.messageStatusResolvers.indexOf(resolve);
if (idx !== -1) { this.messageStatusResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
const reqId = this.makeReqId();
this.messageStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.messageStatusResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "message_status", messageId, _reqId: reqId }));
});
}
// --- Files ---
/** Get a download URL for a shared file. */
async getFile(fileId: string): Promise<{ url: string; name: string } | null> {
async getFile(fileId: string): Promise<{ url: string; name: string; encrypted?: boolean; sealedKey?: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.fileUrlResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_file", fileId }));
setTimeout(() => {
const idx = this.fileUrlResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileUrlResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
const reqId = this.makeReqId();
this.fileUrlResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.fileUrlResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "get_file", fileId, _reqId: reqId }));
});
}
@@ -463,15 +516,11 @@ export class BrokerClient {
async listFiles(query?: string, from?: string): Promise<Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.fileListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_files", query, from }));
setTimeout(() => {
const idx = this.fileListResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileListResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
const reqId = this.makeReqId();
this.fileListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.fileListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_files", query, from, _reqId: reqId }));
});
}
@@ -479,15 +528,11 @@ export class BrokerClient {
async fileStatus(fileId: string): Promise<Array<{ peerName: string; accessedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.fileStatusResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "file_status", fileId }));
setTimeout(() => {
const idx = this.fileStatusResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileStatusResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
const reqId = this.makeReqId();
this.fileStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.fileStatusResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "file_status", fileId, _reqId: reqId }));
});
}
@@ -497,10 +542,11 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
}
/** Upload a file to the broker via HTTP POST. Returns file ID or null. */
/** Upload a file to the broker via HTTP POST. Returns file ID. */
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
}): Promise<string | null> {
encrypted?: boolean; ownerPubkey?: string; fileKeys?: Array<{ peerPubkey: string; sealedKey: string }>;
}): Promise<string> {
const { readFileSync } = await import("node:fs");
const { basename } = await import("node:path");
const data = readFileSync(filePath);
@@ -522,12 +568,30 @@ export class BrokerClient {
"X-Tags": JSON.stringify(opts.tags ?? []),
"X-Persistent": String(opts.persistent ?? true),
"X-Target-Spec": opts.targetSpec ?? "",
...(opts.encrypted ? { "X-Encrypted": "true" } : {}),
...(opts.ownerPubkey ? { "X-Owner-Pubkey": opts.ownerPubkey } : {}),
...(opts.fileKeys?.length ? { "X-File-Keys": JSON.stringify(opts.fileKeys) } : {}),
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = await res.json() as { ok?: boolean; fileId?: string };
return body.fileId ?? null;
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
if (!res.ok || !body.fileId) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body.fileId;
}
/** Grant a peer access to an encrypted file (owner only). */
async grantFileAccess(fileId: string, peerPubkey: string, sealedKey: string): Promise<boolean> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.grantFileAccessResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.grantFileAccessResolvers.delete(reqId)) resolve(false);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "grant_file_access", fileId, peerPubkey, sealedKey, _reqId: reqId }));
});
}
// --- Vectors ---
@@ -536,12 +600,11 @@ export class BrokerClient {
async vectorStore(collection: string, text: string, metadata?: Record<string, unknown>): Promise<string | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.vectorStoredResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "vector_store", collection, text, metadata }));
setTimeout(() => {
const idx = this.vectorStoredResolvers.indexOf(resolve);
if (idx !== -1) { this.vectorStoredResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
const reqId = this.makeReqId();
this.vectorStoredResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.vectorStoredResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "vector_store", collection, text, metadata, _reqId: reqId }));
});
}
@@ -549,12 +612,11 @@ export class BrokerClient {
async vectorSearch(collection: string, query: string, limit?: number): Promise<Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.vectorResultsResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "vector_search", collection, query, limit }));
setTimeout(() => {
const idx = this.vectorResultsResolvers.indexOf(resolve);
if (idx !== -1) { this.vectorResultsResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.vectorResultsResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.vectorResultsResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "vector_search", collection, query, limit, _reqId: reqId }));
});
}
@@ -568,12 +630,11 @@ export class BrokerClient {
async listCollections(): Promise<string[]> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.collectionListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_collections" }));
setTimeout(() => {
const idx = this.collectionListResolvers.indexOf(resolve);
if (idx !== -1) { this.collectionListResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.collectionListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.collectionListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_collections", _reqId: reqId }));
});
}
@@ -583,12 +644,11 @@ export class BrokerClient {
async graphQuery(cypher: string): Promise<Array<Record<string, unknown>>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.graphResultResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "graph_query", cypher }));
setTimeout(() => {
const idx = this.graphResultResolvers.indexOf(resolve);
if (idx !== -1) { this.graphResultResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.graphResultResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.graphResultResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "graph_query", cypher, _reqId: reqId }));
});
}
@@ -596,12 +656,11 @@ export class BrokerClient {
async graphExecute(cypher: string): Promise<Array<Record<string, unknown>>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.graphResultResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "graph_execute", cypher }));
setTimeout(() => {
const idx = this.graphResultResolvers.indexOf(resolve);
if (idx !== -1) { this.graphResultResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.graphResultResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.graphResultResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "graph_execute", cypher, _reqId: reqId }));
});
}
@@ -617,12 +676,11 @@ export class BrokerClient {
async getContext(query: string): Promise<Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.contextResultsResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_context", query }));
setTimeout(() => {
const idx = this.contextResultsResolvers.indexOf(resolve);
if (idx !== -1) { this.contextResultsResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.contextResultsResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.contextResultsResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "get_context", query, _reqId: reqId }));
});
}
@@ -630,12 +688,11 @@ export class BrokerClient {
async listContexts(): Promise<Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.contextListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_contexts" }));
setTimeout(() => {
const idx = this.contextListResolvers.indexOf(resolve);
if (idx !== -1) { this.contextListResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.contextListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.contextListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_contexts", _reqId: reqId }));
});
}
@@ -645,37 +702,35 @@ export class BrokerClient {
async createTask(title: string, assignee?: string, priority?: string, tags?: string[]): Promise<string | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.taskCreatedResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "create_task", title, assignee, priority, tags }));
setTimeout(() => {
const idx = this.taskCreatedResolvers.indexOf(resolve);
if (idx !== -1) { this.taskCreatedResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
const reqId = this.makeReqId();
this.taskCreatedResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.taskCreatedResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "create_task", title, assignee, priority, tags, _reqId: reqId }));
});
}
/** Claim an unclaimed task. */
async claimTask(id: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "claim_task", id }));
this.ws.send(JSON.stringify({ type: "claim_task", taskId: id }));
}
/** Mark a task done with optional result. */
async completeTask(id: string, result?: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "complete_task", id, result }));
this.ws.send(JSON.stringify({ type: "complete_task", taskId: id, result }));
}
/** List tasks filtered by status/assignee. */
async listTasks(status?: string, assignee?: string): Promise<Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.taskListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_tasks", status, assignee }));
setTimeout(() => {
const idx = this.taskListResolvers.indexOf(resolve);
if (idx !== -1) { this.taskListResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.taskListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.taskListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_tasks", status, assignee, _reqId: reqId }));
});
}
@@ -685,12 +740,11 @@ export class BrokerClient {
async meshQuery(sql: string): Promise<{ columns: string[]; rows: Array<Record<string, unknown>>; rowCount: number } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.meshQueryResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "mesh_query", sql }));
setTimeout(() => {
const idx = this.meshQueryResolvers.indexOf(resolve);
if (idx !== -1) { this.meshQueryResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
const reqId = this.makeReqId();
this.meshQueryResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.meshQueryResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "mesh_query", sql, _reqId: reqId }));
});
}
@@ -704,12 +758,11 @@ export class BrokerClient {
async meshSchema(): Promise<Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.meshSchemaResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "mesh_schema" }));
setTimeout(() => {
const idx = this.meshSchemaResolvers.indexOf(resolve);
if (idx !== -1) { this.meshSchemaResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.meshSchemaResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.meshSchemaResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "mesh_schema", _reqId: reqId }));
});
}
@@ -719,12 +772,11 @@ export class BrokerClient {
async createStream(name: string): Promise<string | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.streamCreatedResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "create_stream", name }));
setTimeout(() => {
const idx = this.streamCreatedResolvers.indexOf(resolve);
if (idx !== -1) { this.streamCreatedResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
const reqId = this.makeReqId();
this.streamCreatedResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.streamCreatedResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "create_stream", name, _reqId: reqId }));
});
}
@@ -750,12 +802,11 @@ export class BrokerClient {
async listStreams(): Promise<Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.streamListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_streams" }));
setTimeout(() => {
const idx = this.streamListResolvers.indexOf(resolve);
if (idx !== -1) { this.streamListResolvers.splice(idx, 1); resolve([]); }
}, 5_000);
const reqId = this.makeReqId();
this.streamListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.streamListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_streams", _reqId: reqId }));
});
}
@@ -772,17 +823,16 @@ export class BrokerClient {
}
// --- Mesh info ---
private meshInfoResolvers: Array<(result: Record<string, unknown> | null) => void> = [];
private meshInfoResolvers = new Map<string, { resolve: (result: Record<string, unknown> | null) => void; timer: NodeJS.Timeout }>();
async meshInfo(): Promise<Record<string, unknown> | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.meshInfoResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "mesh_info" }));
setTimeout(() => {
const idx = this.meshInfoResolvers.indexOf(resolve);
if (idx !== -1) { this.meshInfoResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
const reqId = this.makeReqId();
this.meshInfoResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.meshInfoResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "mesh_info", _reqId: reqId }));
});
}
@@ -802,7 +852,33 @@ export class BrokerClient {
// --- Internals ---
private resolveFromMap<T>(
map: Map<string, { resolve: (v: T) => void; timer: NodeJS.Timeout }>,
reqId: string | undefined,
value: T,
): boolean {
let entry = reqId ? map.get(reqId) : undefined;
if (!entry) {
// Fallback: oldest pending (FIFO, for brokers that don't echo _reqId)
const first = map.entries().next().value as [string, { resolve: (v: T) => void; timer: NodeJS.Timeout }] | undefined;
if (first) {
entry = first[1];
map.delete(first[0]);
}
} else {
map.delete(reqId!);
}
if (entry) {
clearTimeout(entry.timer);
entry.resolve(value);
return true;
}
return false;
}
private handleServerMessage(msg: Record<string, unknown>): void {
const msgReqId = msg._reqId as string | undefined;
if (msg.type === "ack") {
const pending = this.pendingSends.get(String(msg.id ?? ""));
if (pending) {
@@ -816,8 +892,7 @@ export class BrokerClient {
}
if (msg.type === "peers_list") {
const peers = (msg.peers as PeerInfo[]) ?? [];
const resolver = this.listPeersResolvers.shift();
if (resolver) resolver(peers);
this.resolveFromMap(this.listPeersResolvers, msgReqId, peers);
return;
}
if (msg.type === "push") {
@@ -876,6 +951,9 @@ export class BrokerClient {
receivedAt: new Date().toISOString(),
plaintext,
kind,
...(msg.subtype ? { subtype: msg.subtype as "reminder" | "system" } : {}),
...(msg.event ? { event: String(msg.event) } : {}),
...(msg.eventData ? { eventData: msg.eventData as Record<string, unknown> } : {}),
};
this.pushBuffer.push(push);
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
@@ -890,25 +968,26 @@ export class BrokerClient {
return;
}
if (msg.type === "state_result") {
const resolver = this.stateResolvers.shift();
if (resolver) {
if (msg.key) {
resolver({
key: String(msg.key),
value: msg.value,
updatedBy: String(msg.updatedBy ?? ""),
updatedAt: String(msg.updatedAt ?? ""),
});
} else {
resolver(null);
}
// DEPENDENCY: The broker must NOT send state_result for set_state
// operations (only for get_state). If the broker sends state_result for
// both, it would be consumed here by the next pending get_state resolver,
// returning the wrong value (cross-contamination). The broker's set_state
// handler was fixed to omit state_result; only get_state sends it.
if (msg.key) {
this.resolveFromMap(this.stateResolvers, msgReqId, {
key: String(msg.key),
value: msg.value,
updatedBy: String(msg.updatedBy ?? ""),
updatedAt: String(msg.updatedAt ?? ""),
});
} else {
this.resolveFromMap(this.stateResolvers, msgReqId, null);
}
return;
}
if (msg.type === "state_list") {
const entries = (msg.entries as Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) ?? [];
const resolver = this.stateListResolvers.shift();
if (resolver) resolver(entries);
this.resolveFromMap(this.stateListResolvers, msgReqId, entries);
return;
}
if (msg.type === "state_change") {
@@ -923,120 +1002,108 @@ export class BrokerClient {
return;
}
if (msg.type === "memory_stored") {
const resolver = this.memoryStoreResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
this.resolveFromMap(this.memoryStoreResolvers, msgReqId, msg.id ? String(msg.id) : null);
return;
}
if (msg.type === "memory_results") {
const memories = (msg.memories as Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) ?? [];
const resolver = this.memoryRecallResolvers.shift();
if (resolver) resolver(memories);
this.resolveFromMap(this.memoryRecallResolvers, msgReqId, memories);
return;
}
if (msg.type === "message_status_result") {
const resolver = this.messageStatusResolvers.shift();
if (resolver) resolver(msg as any);
this.resolveFromMap(this.messageStatusResolvers, msgReqId, msg as any);
return;
}
if (msg.type === "file_url") {
const resolver = this.fileUrlResolvers.shift();
if (resolver) {
if (msg.url) {
resolver({ url: String(msg.url), name: String(msg.name ?? "") });
} else {
resolver(null);
}
if (msg.url) {
this.resolveFromMap(this.fileUrlResolvers, msgReqId, {
url: String(msg.url),
name: String(msg.name ?? ""),
encrypted: msg.encrypted ? true : undefined,
sealedKey: msg.sealedKey ? String(msg.sealedKey) : undefined,
});
} else {
this.resolveFromMap(this.fileUrlResolvers, msgReqId, null);
}
return;
}
if (msg.type === "file_list") {
const files = (msg.files as Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) ?? [];
const resolver = this.fileListResolvers.shift();
if (resolver) resolver(files);
this.resolveFromMap(this.fileListResolvers, msgReqId, files);
return;
}
if (msg.type === "file_status_result") {
const accesses = (msg.accesses as Array<{ peerName: string; accessedAt: string }>) ?? [];
const resolver = this.fileStatusResolvers.shift();
if (resolver) resolver(accesses);
this.resolveFromMap(this.fileStatusResolvers, msgReqId, accesses);
return;
}
if (msg.type === "grant_file_access_ok") {
this.resolveFromMap(this.grantFileAccessResolvers, msgReqId, true);
return;
}
if (msg.type === "vector_stored") {
const resolver = this.vectorStoredResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
this.resolveFromMap(this.vectorStoredResolvers, msgReqId, msg.id ? String(msg.id) : null);
return;
}
if (msg.type === "vector_results") {
const results = (msg.results as Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) ?? [];
const resolver = this.vectorResultsResolvers.shift();
if (resolver) resolver(results);
this.resolveFromMap(this.vectorResultsResolvers, msgReqId, results);
return;
}
if (msg.type === "collection_list") {
const collections = (msg.collections as string[]) ?? [];
const resolver = this.collectionListResolvers.shift();
if (resolver) resolver(collections);
this.resolveFromMap(this.collectionListResolvers, msgReqId, collections);
return;
}
if (msg.type === "graph_result") {
const rows = (msg.rows as Array<Record<string, unknown>>) ?? [];
const resolver = this.graphResultResolvers.shift();
if (resolver) resolver(rows);
// Broker sends { type: "graph_result", records: [...] }
const rows = (msg.records as Array<Record<string, unknown>>) ?? [];
this.resolveFromMap(this.graphResultResolvers, msgReqId, rows);
return;
}
if (msg.type === "context_list") {
const contexts = (msg.contexts as Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) ?? [];
const resolver = this.contextListResolvers.shift();
if (resolver) resolver(contexts);
this.resolveFromMap(this.contextListResolvers, msgReqId, contexts);
return;
}
if (msg.type === "context_results") {
const contexts = (msg.contexts as Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) ?? [];
const resolver = this.contextResultsResolvers.shift();
if (resolver) resolver(contexts);
this.resolveFromMap(this.contextResultsResolvers, msgReqId, contexts);
return;
}
if (msg.type === "task_created") {
const resolver = this.taskCreatedResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
this.resolveFromMap(this.taskCreatedResolvers, msgReqId, msg.id ? String(msg.id) : null);
return;
}
if (msg.type === "task_list") {
const tasks = (msg.tasks as Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) ?? [];
const resolver = this.taskListResolvers.shift();
if (resolver) resolver(tasks);
this.resolveFromMap(this.taskListResolvers, msgReqId, tasks);
return;
}
if (msg.type === "mesh_query_result") {
const resolver = this.meshQueryResolvers.shift();
if (resolver) {
if (msg.columns) {
resolver({
columns: (msg.columns as string[]) ?? [],
rows: (msg.rows as Array<Record<string, unknown>>) ?? [],
rowCount: (msg.rowCount as number) ?? 0,
});
} else {
resolver(null);
}
if (msg.columns) {
this.resolveFromMap(this.meshQueryResolvers, msgReqId, {
columns: (msg.columns as string[]) ?? [],
rows: (msg.rows as Array<Record<string, unknown>>) ?? [],
rowCount: (msg.rowCount as number) ?? 0,
});
} else {
this.resolveFromMap(this.meshQueryResolvers, msgReqId, null);
}
return;
}
if (msg.type === "mesh_schema_result") {
const tables = (msg.tables as Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) ?? [];
const resolver = this.meshSchemaResolvers.shift();
if (resolver) resolver(tables);
this.resolveFromMap(this.meshSchemaResolvers, msgReqId, tables);
return;
}
if (msg.type === "stream_created") {
const resolver = this.streamCreatedResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
this.resolveFromMap(this.streamCreatedResolvers, msgReqId, msg.id ? String(msg.id) : null);
return;
}
if (msg.type === "stream_list") {
const streams = (msg.streams as Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) ?? [];
const resolver = this.streamListResolvers.shift();
if (resolver) resolver(streams);
this.resolveFromMap(this.streamListResolvers, msgReqId, streams);
return;
}
if (msg.type === "stream_data") {
@@ -1051,13 +1118,30 @@ export class BrokerClient {
return;
}
if (msg.type === "mesh_info_result") {
const resolver = this.meshInfoResolvers.shift();
if (resolver) resolver(msg as Record<string, unknown>);
this.resolveFromMap(this.meshInfoResolvers, msgReqId, msg as Record<string, unknown>);
return;
}
if (msg.type === "scheduled_ack") {
this.resolveFromMap(this.scheduledAckResolvers, msgReqId, {
scheduledId: String(msg.scheduledId ?? ""),
deliverAt: Number(msg.deliverAt ?? 0),
...(msg.cron ? { cron: String(msg.cron) } : {}),
});
return;
}
if (msg.type === "scheduled_list") {
const messages = (msg.messages as Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) ?? [];
this.resolveFromMap(this.scheduledListResolvers, msgReqId, messages);
return;
}
if (msg.type === "cancel_scheduled_ack") {
this.resolveFromMap(this.cancelScheduledResolvers, msgReqId, Boolean(msg.ok));
return;
}
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null;
let handledByPendingSend = false;
if (id) {
const pending = this.pendingSends.get(id);
if (pending) {
@@ -1066,6 +1150,49 @@ export class BrokerClient {
error: `${msg.code}: ${msg.message}`,
});
this.pendingSends.delete(id);
handledByPendingSend = true;
}
}
if (!handledByPendingSend) {
// Best-effort: unblock the first waiting resolver so callers don't
// hang for 5s. We don't know which tool triggered the error, so we
// pop the first non-empty resolver map in priority order.
const allMaps: Array<[Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>, unknown]> = [
[this.stateResolvers, null],
[this.stateListResolvers, []],
[this.memoryStoreResolvers, null],
[this.memoryRecallResolvers, []],
[this.fileUrlResolvers, null],
[this.fileListResolvers, []],
[this.fileStatusResolvers, []],
[this.graphResultResolvers, []],
[this.vectorStoredResolvers, null],
[this.vectorResultsResolvers, []],
[this.taskListResolvers, []],
[this.meshQueryResolvers, null],
[this.contextResultsResolvers, []],
[this.contextListResolvers, []],
[this.streamListResolvers, []],
[this.scheduledAckResolvers, null],
[this.scheduledListResolvers, []],
[this.cancelScheduledResolvers, false],
[this.messageStatusResolvers, null],
[this.grantFileAccessResolvers, false],
[this.collectionListResolvers, []],
[this.meshSchemaResolvers, []],
[this.taskCreatedResolvers, null],
[this.streamCreatedResolvers, null],
[this.listPeersResolvers, []],
[this.meshInfoResolvers, null],
];
for (const [map, defaultVal] of allMaps) {
const first = (map as Map<string, any>).entries().next().value as [string, { resolve: (v: unknown) => void; timer: NodeJS.Timeout }] | undefined;
if (first) {
(map as Map<string, any>).delete(first[0]);
clearTimeout(first[1].timer);
first[1].resolve(defaultVal);
break; // only pop one
}
}
}
}

View File

@@ -12,6 +12,7 @@ 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> {
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
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.
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
/** Start clients for every joined mesh. Called once on MCP server start. */
export async function startClients(config: Config): Promise<void> {
configDisplayName = config.displayName;
configGroups = config.groups ?? [];
await Promise.allSettled(config.meshes.map(ensureClient));
}

View File

@@ -25,6 +25,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
# TURBOPACK=0 forces webpack for production build — Payload CMS's
# richtext-lexical CSS imports fail under Turbopack.
ENV TURBOPACK=0
RUN npx turbo run build --filter=web...
# Stage 2: runtime — standalone output only

View File

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

View File

@@ -4,6 +4,8 @@ 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) =>

View File

@@ -0,0 +1,548 @@
import Link from "next/link";
import { getMetadata } from "~/lib/metadata";
export const metadata = getMetadata({
title: "Getting Started",
description:
"Install claudemesh, join a mesh, and launch your first peer session in under two minutes.",
})();
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 MCP registered in ~/.claude.json",
"Status hooks registered in ~/.claude/settings.json",
"~/.claudemesh/config.json parses + chmod 0600",
"Mesh keypairs valid",
];
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, join a mesh, and launch Claude Code with real-time peer
messaging. Three commands.
</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="curl -fsSL https://claudemesh.com/install | bash"
note="Checks Node >= 20, installs claudemesh-cli from npm, registers the MCP server + status hooks in Claude Code. Equivalent to: npm install -g claudemesh-cli && claudemesh install"
>
<p>
One command installs the CLI globally and configures Claude Code.
The script is short and auditable {" "}
<Link
href="https://claudemesh.com/install"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
>
read it first
</Link>{" "}
if you prefer.
</p>
</STEP>
<div
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
or install manually:
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
npm install -g claudemesh-cli && claudemesh install
</code>
</div>
<STEP
n="2"
title="Restart Claude Code"
note="The MCP server and status hooks registered in step 1 only take effect after a restart."
>
<p>
Close and reopen Claude Code (or your IDE with Claude Code
extension). This loads the claudemesh MCP server so the 43 mesh
tools appear.
</p>
</STEP>
<STEP
n="3"
title="Join a mesh"
cmd="claudemesh join https://claudemesh.com/join/eyJ2IjoxLC..."
note="Replace the URL with your actual invite link. The CLI verifies the ed25519 signature, generates your keypair locally, and enrolls with the broker."
>
<p>
Paste the invite link you received. Your ed25519 keypair is
generated and 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). You keep your keys the broker never sees them.
</p>
</STEP>
<STEP
n="4"
title="Launch with real-time messaging"
cmd="claudemesh launch --name Alice"
note="Wraps `claude` with the mesh dev-channel. Peers can message you in real-time. Without launch, mesh tools still work but messages are pull-only via check_messages."
>
<p>
This spawns Claude Code connected to the mesh with push messaging.
The interactive wizard asks for your role and groups or pass them
as flags:
</p>
</STEP>
<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 \\
--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.6.8)
────────────────────────────────────────────────────────────
✓ Node.js >= 20 (v22.15.0)
✓ claude binary on PATH
✓ claudemesh MCP registered in ~/.claude.json
✓ Status hooks registered in ~/.claude/settings.json
✓ ~/.claudemesh/config.json parses + chmod 0600
✓ Mesh keypairs valid (1 mesh(es))
All checks passed.`}</code>
</pre>
</div>
{/* What install does */}
<div className="mt-16">
<h2
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
What <code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh install</code> does
</h2>
<p
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
The install command touches two files. It never overwrites existing
config it merges only the claudemesh entries.
</p>
<div className="space-y-4">
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
<div
className="mb-2 text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
~/.claude.json
</div>
<p
className="text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Registers{" "}
<code
className="rounded bg-[var(--cm-bg)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
mcpServers.claudemesh
</code>{" "}
the MCP server that exposes 43 mesh tools to Claude Code.
Backed up before every write.
</p>
</div>
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
<div
className="mb-2 text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
~/.claude/settings.json
</div>
<p
className="text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Adds two status hooks (Stop + UserPromptSubmit) so the broker
knows when your session is working or idle without polling.
Pre-approves all 43 claudemesh tools in{" "}
<code
className="rounded bg-[var(--cm-bg)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
allowedTools
</code>{" "}
so they run without --dangerously-skip-permissions.
</p>
</div>
</div>
</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 join &lt;link&gt;
</code>{" "}
the CLI verifies the signature client-side before enrolling with
the broker. 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>{`# HTTPS link (clickable, shareable)
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
# With locale prefix (also works)
claudemesh join https://claudemesh.com/en/join/eyJ2IjoxLC...
# ic:// scheme (legacy, still supported)
claudemesh join ic://join/eyJ2IjoxLC...
# Raw token (last resort)
claudemesh join eyJ2IjoxLC4uLg`}</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>Per-session ephemeral keypair</li>
<li>Display name visible to other peers</li>
<li>Groups and roles set at launch</li>
<li>Session config isolated in tmpdir</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

@@ -3,6 +3,7 @@ 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 { MeshVsMcp } from "~/modules/marketing/home/mesh-vs-mcp";
import { MeetsYou } from "~/modules/marketing/home/meets-you";
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
@@ -28,6 +29,7 @@ const HomePage = () => {
<Pricing />
<LaptopToLaptop />
<Features />
<MeshVsMcp />
<MeetsYou />
<WhatIsClaudemesh />
<DemoDashboard />

View File

@@ -15,6 +15,7 @@ import {
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
export const generateMetadata = getMetadata({
title: "Live mesh",
@@ -63,7 +64,10 @@ 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>
</>
);
}

View File

@@ -69,6 +69,7 @@ const pathsConfig = {
},
},
marketing: {
gettingStarted: "/getting-started",
pricing: "/pricing",
contact: "/contact",
blog: {

View File

@@ -6,7 +6,7 @@ interface Props {
}
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
const INSTALL_CMD = "npx claudemesh@latest init";
const INSTALL_CMD = "curl -fsSL https://claudemesh.com/install | bash";
export const InstallToggle = ({ token }: Props) => {
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
@@ -106,7 +106,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 +127,8 @@ 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.
Installs the CLI, registers the MCP server + status hooks in
Claude Code. Restart Claude Code after this step.
</p>
</li>
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
@@ -161,14 +161,24 @@ export const InstallToggle = ({ token }: Props) => {
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
verify
launch with push messaging
</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)" }}
>
claudemesh launch --name YourName
</code>
</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.
Restart Claude Code first, then launch. Peers see you appear on
the mesh. Or run plain{" "}
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>{" "}
tools work, but messages are pull-only.
</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: `curl -fsSL https://claudemesh.com/install | bash` — this checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then restart Claude Code. Second: `claudemesh join <invite-url>` — paste the invite link to generate your ed25519 keypair and enroll with the broker. Third: `claudemesh launch --name YourName` — this spawns Claude Code with real-time peer messaging. See the Getting Started guide for full details.",
},
{
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

@@ -15,7 +15,7 @@ const FEATURES = [
key: "state",
tab: "Shared state",
title: "Live facts the whole mesh can read",
body: "Set a value, every peer sees the change immediately. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
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`,
@@ -24,10 +24,37 @@ get_state("deploy_frozen") → true`,
key: "memory",
tab: "Memory",
title: "The mesh gets smarter over time",
body: "New peers join with zero context. Memory stores institutional knowledge — decisions, incidents, lessons. Full-text searchable. Survives across sessions. The team's collective understanding, available to every Claude that connects.",
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",
@@ -36,8 +63,8 @@ recall("rate limit") → ranked results`,
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")
send_message(to: "@pm",
message: "auth v2 done, 3 points, no blockers")`,
create_task(title: "bump env loader", assignee: "jordan")
complete_task(id: "t1", result: "env.ts updated, PR #42")`,
},
];
@@ -63,7 +90,7 @@ export const Features = () => {
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
30+ MCP tools. Groups, state, memory, messaging all shipped.
43 MCP tools. Groups, state, memory, files, databases, vectors, streams all shipped.
</p>
</Reveal>
<Reveal delay={3}>

View File

@@ -56,8 +56,9 @@ export const Hero = () => {
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Your Claude Code sessions form a team. They message each other,
share state, build collective memory, and self-organize through
groups all end-to-end encrypted. One command to launch. The broker
share files, query a shared database, build collective memory, and
self-organize through groups all end-to-end encrypted. 43 MCP
tools. Five persistence backends. One command to launch. The broker
routes ciphertext; it never reads your messages.
<span className="block pt-2 text-[var(--cm-clay)]">
Open-source CLI. Free during public beta.
@@ -94,10 +95,10 @@ export const Hero = () => {
>
Or{" "}
<Link
href="https://github.com/alezmad/claudemesh-cli#readme"
href="/getting-started"
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
read the getting started guide
</Link>
</p>
</Reveal>

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 = [

View File

@@ -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>
@@ -457,10 +458,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,6 +1,7 @@
import Link from "next/link";
const NAV = [
{ label: "Getting Started", href: "/getting-started" },
{ label: "Docs", href: "#docs" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "#changelog" },

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>
);
};

View File

@@ -0,0 +1,462 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { PeerStatus } from "~/modules/marketing/home/mesh-stream";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface GraphPeer {
id: string;
name: string;
status: PeerStatus;
summary?: string;
/** Number of messages sent by this peer — drives node sizing */
messageCount: number;
/** Group names this peer belongs to */
groups?: string[];
}
export interface GraphEdge {
key: string;
from: string;
to: string | null; // null = broadcast (draw to all)
priority: "now" | "next" | "low";
createdAt: Date;
}
export interface PeerGraphProps {
peers: GraphPeer[];
edges: GraphEdge[];
meshName?: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const STATUS_COLOR: Record<PeerStatus, string> = {
idle: "#22c55e", // emerald-500
working: "#d97757", // --cm-clay
dnd: "#c46686", // --cm-fig
offline: "#87867f", // --cm-fg-tertiary
};
const PRIORITY_COLOR: Record<string, string> = {
low: "#22c55e",
next: "#c2c0b6",
now: "#ef4444",
};
/** How long edges remain visible (ms) */
const EDGE_TTL_MS = 8_000;
/** Ring colors for groups — up to 8 distinct groups */
const GROUP_RING_COLORS = [
"#d97757", // clay
"#c46686", // fig
"#bcd1ca", // cactus
"#e3dacc", // oat
"#6ea8fe", // blue
"#fbbf24", // amber
"#a78bfa", // violet
"#f472b6", // pink
];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Radial layout: peers on a circle, center reserved for mesh label. */
const computeLayout = (
peerCount: number,
width: number,
height: number,
) => {
const cx = width / 2;
const cy = height / 2;
const radius = Math.min(cx, cy) * 0.68;
return { cx, cy, radius };
};
const peerPosition = (
index: number,
total: number,
cx: number,
cy: number,
radius: number,
) => {
const angle = (index / total) * 2 * Math.PI - Math.PI / 2; // start at top
return {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
};
};
/** Scale node radius based on message volume relative to peers. */
const nodeRadius = (count: number, maxCount: number) => {
const base = 22;
const extra = 12;
if (maxCount === 0) return base;
return base + (count / maxCount) * extra;
};
/** Build a group-color map from all peers. */
const buildGroupColorMap = (peers: GraphPeer[]) => {
const seen = new Set<string>();
for (const p of peers) {
for (const g of p.groups ?? []) seen.add(g);
}
const map = new Map<string, string>();
let i = 0;
for (const g of seen) {
map.set(g, GROUP_RING_COLORS[i % GROUP_RING_COLORS.length]!);
i++;
}
return map;
};
/** Quadratic bezier control point offset for curved edges */
const curveOffset = (
x1: number,
y1: number,
x2: number,
y2: number,
cx: number,
cy: number,
) => {
// Push the control point toward center for a slight curve
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const factor = 0.15;
return {
qx: mx + (cx - mx) * factor,
qy: my + (cy - my) * factor,
};
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export const PeerGraph = ({ peers, edges, meshName }: PeerGraphProps) => {
const svgRef = useRef<SVGSVGElement>(null);
const [dimensions, setDimensions] = useState({ width: 520, height: 520 });
const [now, setNow] = useState(Date.now());
// Tick every second to fade edges
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// Responsive resize
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) setDimensions({ width, height });
});
ro.observe(svg);
return () => ro.disconnect();
}, []);
const { width, height } = dimensions;
const { cx, cy, radius } = computeLayout(peers.length, width, height);
const maxCount = useMemo(
() => Math.max(1, ...peers.map((p) => p.messageCount)),
[peers],
);
const groupColorMap = useMemo(() => buildGroupColorMap(peers), [peers]);
// Map peer id -> position
const posMap = useMemo(() => {
const m = new Map<string, { x: number; y: number }>();
peers.forEach((p, i) => {
m.set(p.id, peerPosition(i, peers.length, cx, cy, radius));
});
return m;
}, [peers, cx, cy, radius]);
// Filter edges to those still visible
const visibleEdges = useMemo(
() => edges.filter((e) => now - e.createdAt.getTime() < EDGE_TTL_MS),
[edges, now],
);
// Build edge paths: direct -> single path, broadcast -> one path per peer
const edgePaths = useMemo(() => {
const paths: {
key: string;
d: string;
color: string;
opacity: number;
}[] = [];
for (const e of visibleEdges) {
const fromPos = posMap.get(e.from);
if (!fromPos) continue;
const age = now - e.createdAt.getTime();
const opacity = Math.max(0, 1 - age / EDGE_TTL_MS);
const color = PRIORITY_COLOR[e.priority] ?? PRIORITY_COLOR.next!;
if (e.to === null || e.to === "*") {
// Broadcast: lines to all other peers
for (const [pid, pos] of posMap) {
if (pid === e.from) continue;
const { qx, qy } = curveOffset(
fromPos.x,
fromPos.y,
pos.x,
pos.y,
cx,
cy,
);
paths.push({
key: `${e.key}-${pid}`,
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${pos.x},${pos.y}`,
color,
opacity: opacity * 0.6,
});
}
} else {
const toPos = posMap.get(e.to);
if (!toPos) continue;
const { qx, qy } = curveOffset(
fromPos.x,
fromPos.y,
toPos.x,
toPos.y,
cx,
cy,
);
paths.push({
key: e.key,
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${toPos.x},${toPos.y}`,
color,
opacity,
});
}
}
return paths;
}, [visibleEdges, posMap, cx, cy, now]);
return (
<svg
ref={svgRef}
className="h-full w-full"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={`Peer graph for mesh${meshName ? ` "${meshName}"` : ""} showing ${peers.length} peers and recent message traffic`}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{/* Subtle radial grid */}
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="var(--cm-border)"
strokeWidth="1"
strokeDasharray="4 6"
opacity="0.4"
/>
<circle
cx={cx}
cy={cy}
r={radius * 0.5}
fill="none"
stroke="var(--cm-border)"
strokeWidth="0.5"
strokeDasharray="2 4"
opacity="0.2"
/>
{/* Center mesh label */}
{meshName && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-tertiary)"
fontSize="11"
opacity="0.5"
>
{meshName}
</text>
)}
{/* Edges */}
<g>
{edgePaths.map((ep) => (
<path
key={ep.key}
d={ep.d}
fill="none"
stroke={ep.color}
strokeWidth="1.5"
opacity={ep.opacity}
style={{
transition: "opacity 1s ease-out",
}}
/>
))}
</g>
{/* Animated pulse dots traveling along edges */}
{edgePaths
.filter((ep) => ep.opacity > 0.3)
.map((ep) => (
<circle key={`dot-${ep.key}`} r="2.5" fill={ep.color} opacity={ep.opacity}>
<animateMotion
dur="1.2s"
repeatCount="1"
path={ep.d}
fill="freeze"
/>
</circle>
))}
{/* Peer nodes */}
{peers.map((peer, i) => {
const pos = posMap.get(peer.id);
if (!pos) return null;
const r = nodeRadius(peer.messageCount, maxCount);
const groups = peer.groups ?? [];
return (
<g key={peer.id}>
{/* Group rings (concentric, outermost first) */}
{groups.map((g, gi) => {
const ringR = r + 5 + gi * 4;
const ringColor = groupColorMap.get(g) ?? GROUP_RING_COLORS[0]!;
return (
<circle
key={g}
cx={pos.x}
cy={pos.y}
r={ringR}
fill="none"
stroke={ringColor}
strokeWidth="2"
strokeDasharray="6 3"
opacity="0.55"
/>
);
})}
{/* Outer glow for active status */}
{peer.status === "working" && (
<circle
cx={pos.x}
cy={pos.y}
r={r + 2}
fill="none"
stroke={STATUS_COLOR.working}
strokeWidth="1"
opacity="0.3"
>
<animate
attributeName="r"
values={`${r + 2};${r + 6};${r + 2}`}
dur="2s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.3;0.08;0.3"
dur="2s"
repeatCount="indefinite"
/>
</circle>
)}
{/* Node circle */}
<circle
cx={pos.x}
cy={pos.y}
r={r}
fill="var(--cm-bg-elevated)"
stroke={STATUS_COLOR[peer.status]}
strokeWidth="2"
style={{ transition: "all 0.6s var(--cm-ease)" }}
/>
{/* Status indicator dot */}
<circle
cx={pos.x + r * 0.6}
cy={pos.y - r * 0.6}
r="4"
fill={STATUS_COLOR[peer.status]}
stroke="var(--cm-bg)"
strokeWidth="1.5"
/>
{/* Initials inside node */}
<text
x={pos.x}
y={pos.y + 1}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg)"
fontSize="11"
fontWeight="600"
>
{peer.name.slice(0, 2).toUpperCase()}
</text>
{/* Name label below */}
<text
x={pos.x}
y={pos.y + r + 14}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-secondary)"
fontSize="10"
>
{peer.name.length > 12
? peer.name.slice(0, 11) + "\u2026"
: peer.name}
</text>
{/* Truncated summary below name */}
{peer.summary && (
<text
x={pos.x}
y={pos.y + r + 26}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-tertiary)"
fontSize="8"
>
{peer.summary.length > 24
? peer.summary.slice(0, 23) + "\u2026"
: peer.summary}
</text>
)}
</g>
);
})}
{/* Empty state */}
{peers.length === 0 && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-tertiary)"
fontSize="12"
>
No peers connected
</text>
)}
</svg>
);
};

9036
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,86 @@ leaves the peer.
All broker ↔ peer traffic is line-delimited JSON on a single WebSocket.
| Type | Direction | Purpose |
|--------------|---------------|----------------------------------------------------|
| `hello` | peer → broker | signed handshake — proves control of ed25519 key |
| `hello_ack` | broker → peer | confirms identity + returns current mesh presence |
| `send` | peer → broker | ciphertext envelope addressed to one or more peers |
| `ack` | broker → peer | broker-side delivery receipt for a `send` |
| `push` | broker → peer | an inbound envelope the broker is forwarding |
| `error` | broker → peer | handshake or authorization failure |
| Type | Direction | Purpose |
|------------------------|---------------|----------------------------------------------------|
| `hello` | peer → broker | signed handshake — proves control of ed25519 key |
| `hello_ack` | broker → peer | confirms identity + returns current mesh presence |
| `send` | peer → broker | ciphertext envelope addressed to one or more peers |
| `ack` | broker → peer | broker-side delivery receipt for a `send` |
| `push` | broker → peer | an inbound envelope the broker is forwarding |
| `set_status` | peer → broker | manual status override (idle, working, dnd) |
| `set_summary` | peer → broker | update the session's human-readable summary |
| `list_peers` | peer → broker | request connected peers in the same mesh |
| `peers_list` | broker → peer | response to `list_peers` |
| `join_group` | peer → broker | join a named group with optional role |
| `leave_group` | peer → broker | leave a named group |
| `set_state` | peer → broker | write a shared key-value pair |
| `get_state` | peer → broker | read a shared state key |
| `list_state` | peer → broker | list all shared state entries |
| `state_change` | broker → peer | a state key was changed by another peer |
| `state_result` | broker → peer | response to `get_state` |
| `state_list` | broker → peer | response to `list_state` |
| `remember` | peer → broker | store a persistent memory |
| `recall` | peer → broker | full-text search over memories |
| `forget` | peer → broker | soft-delete a memory |
| `memory_stored` | broker → peer | acknowledgement for `remember` |
| `memory_results` | broker → peer | response to `recall` |
| `message_status` | peer → broker | check delivery status of a sent message |
| `message_status_result`| broker → peer | per-recipient delivery detail |
| `share_context` | peer → broker | share current working context |
| `get_context` | peer → broker | search shared contexts by query |
| `list_contexts` | peer → broker | list all shared contexts |
| `context_shared` | broker → peer | acknowledgement for `share_context` |
| `context_results` | broker → peer | response to `get_context` |
| `context_list` | broker → peer | response to `list_contexts` |
| `create_task` | peer → broker | create a task |
| `claim_task` | peer → broker | claim an open task |
| `complete_task` | peer → broker | mark a task as done |
| `list_tasks` | peer → broker | list tasks with optional filters |
| `task_created` | broker → peer | acknowledgement for `create_task` |
| `task_list` | broker → peer | response to task queries |
| `vector_store` | peer → broker | store a document in a vector collection |
| `vector_search` | peer → broker | search a vector collection |
| `vector_delete` | peer → broker | delete a point from a vector collection |
| `list_collections` | peer → broker | list all vector collections |
| `vector_stored` | broker → peer | acknowledgement for `vector_store` |
| `vector_results` | broker → peer | response to `vector_search` |
| `collection_list` | broker → peer | response to `list_collections` |
| `graph_query` | peer → broker | run a read-only Cypher query |
| `graph_execute` | peer → broker | run a write Cypher statement |
| `graph_result` | broker → peer | response to graph queries |
| `mesh_query` | peer → broker | run a SELECT in the mesh's schema |
| `mesh_execute` | peer → broker | run DDL/DML in the mesh's schema |
| `mesh_schema` | peer → broker | list tables and columns in the mesh's schema |
| `mesh_query_result` | broker → peer | response to `mesh_query` |
| `mesh_schema_result` | broker → peer | response to `mesh_schema` |
| `mesh_info` | peer → broker | request full mesh overview |
| `mesh_info_result` | broker → peer | aggregated mesh overview |
| `create_stream` | peer → broker | create a named real-time stream |
| `publish` | peer → broker | publish data to a stream |
| `subscribe` | peer → broker | subscribe to a stream |
| `unsubscribe` | peer → broker | unsubscribe from a stream |
| `list_streams` | peer → broker | list all streams in the mesh |
| `stream_created` | broker → peer | acknowledgement for `create_stream` |
| `stream_data` | broker → peer | real-time data pushed from a stream |
| `subscribed` | broker → peer | confirmation of stream subscription |
| `stream_list` | broker → peer | response to `list_streams` |
| `schedule` | peer → broker | schedule a message for future or recurring delivery|
| `list_scheduled` | peer → broker | list pending scheduled messages |
| `cancel_scheduled` | peer → broker | cancel a scheduled message by id |
| `scheduled_ack` | broker → peer | acknowledgement for `schedule` |
| `scheduled_list` | broker → peer | response to `list_scheduled` |
| `cancel_scheduled_ack` | broker → peer | confirmation of cancellation |
| `get_file` | peer → broker | request a presigned download URL |
| `list_files` | peer → broker | list files in the mesh |
| `file_status` | peer → broker | get access log for a file |
| `delete_file` | peer → broker | soft-delete a file |
| `grant_file_access` | peer → broker | grant a peer access to an encrypted file |
| `file_url` | broker → peer | presigned download URL |
| `file_list` | broker → peer | response to `list_files` |
| `file_status_result` | broker → peer | access log for a file |
| `grant_file_access_ok` | broker → peer | acknowledgement for `grant_file_access` |
| `error` | broker → peer | structured error (handshake, auth, or runtime) |
Each message carries a monotonic `seq`, a mesh id, and the sender's
public key fingerprint. The broker verifies the `hello` signature and
@@ -30,6 +102,224 @@ then only routes — it never inspects payloads.
---
## Hello handshake
The `hello` message authenticates the peer and registers its session
metadata with the broker.
```jsonc
{
"type": "hello",
"meshId": "acme-payments",
"memberId": "m_abc123",
"pubkey": "<ed25519 hex>",
"sessionPubkey": "<ephemeral ed25519 hex>", // optional
"displayName": "Mou", // optional
"sessionId": "w1t0p0",
"pid": 42781,
"cwd": "/home/user/project",
"peerType": "ai", // "ai" | "human" | "connector"
"channel": "claude-code", // e.g. "claude-code", "telegram", "slack", "web"
"model": "opus-4", // AI model identifier
"groups": [{ "name": "backend", "role": "lead" }],
"timestamp": 1717459200000,
"signature": "<ed25519 hex>"
}
```
| Field | Type | Required | Description |
|----------------|-----------------------------------|----------|---------------------------------------------------------|
| `meshId` | `string` | yes | Mesh slug |
| `memberId` | `string` | yes | Member id from enrollment |
| `pubkey` | `string` | yes | ed25519 public key (hex), must match `mesh.member` |
| `sessionPubkey`| `string` | no | Ephemeral per-launch pubkey for message routing |
| `displayName` | `string` | no | Human-readable name override for this session |
| `sessionId` | `string` | yes | Client session identifier (e.g. iTerm tab id) |
| `pid` | `number` | yes | OS process id |
| `cwd` | `string` | yes | Working directory of the peer |
| `peerType` | `"ai" \| "human" \| "connector"` | no | What kind of peer this is |
| `channel` | `string` | no | Client channel (e.g. `"claude-code"`, `"slack"`, `"web"`) |
| `model` | `string` | no | AI model identifier (e.g. `"opus-4"`, `"sonnet-4"`) |
| `groups` | `Array<{name, role?}>` | no | Groups to join on connect |
| `timestamp` | `number` | yes | ms epoch; broker rejects if outside ±60 s of its clock |
| `signature` | `string` | yes | ed25519 signature over `${meshId}\|${memberId}\|${pubkey}\|${timestamp}` |
---
## Peer list
The `peers_list` response includes session metadata for each connected
peer, mirroring the fields sent in `hello`.
```jsonc
{
"type": "peers_list",
"peers": [
{
"pubkey": "<ed25519 hex>",
"displayName": "Mou",
"status": "working",
"summary": "Refactoring the scheduler",
"groups": [{ "name": "backend", "role": "lead" }],
"sessionId": "w1t0p0",
"connectedAt": "2025-06-04T10:30:00Z",
"cwd": "/home/user/project",
"peerType": "ai",
"channel": "claude-code",
"model": "opus-4"
}
]
}
```
| Field | Type | Required | Description |
|---------------|-----------------------------------|----------|----------------------------------------------|
| `pubkey` | `string` | yes | Peer's ed25519 public key (hex) |
| `displayName` | `string` | yes | Human-readable name |
| `status` | `PeerStatus` | yes | `"idle"`, `"working"`, or `"dnd"` |
| `summary` | `string \| null` | yes | Session summary set by the peer |
| `groups` | `Array<{name, role?}>` | yes | Groups the peer belongs to |
| `sessionId` | `string` | yes | Client session identifier |
| `connectedAt` | `string` | yes | ISO 8601 timestamp |
| `cwd` | `string` | no | Working directory |
| `peerType` | `"ai" \| "human" \| "connector"` | no | Peer kind |
| `channel` | `string` | no | Client channel |
| `model` | `string` | no | AI model identifier |
---
## System notifications
The broker broadcasts topology events as `push` messages with
`subtype: "system"`. These are not encrypted — the broker generates
them directly.
```jsonc
{
"type": "push",
"messageId": "msg_xyz",
"meshId": "acme-payments",
"senderPubkey": "<broker pubkey>",
"priority": "low",
"nonce": "",
"ciphertext": "",
"createdAt": "2025-06-04T10:30:00Z",
"subtype": "system",
"event": "peer_joined",
"eventData": {
"pubkey": "<ed25519 hex>",
"displayName": "Mou",
"peerType": "ai"
}
}
```
| Field | Type | Required | Description |
|-------------|----------------------------|----------|----------------------------------------------------|
| `subtype` | `"reminder" \| "system"` | no | `"system"` for topology events, `"reminder"` for scheduled deliveries |
| `event` | `string` | no | Machine-readable event name (e.g. `"peer_joined"`, `"peer_left"`) |
| `eventData` | `Record<string, unknown>` | no | Structured payload for the event |
The standard `push` fields (`messageId`, `meshId`, `senderPubkey`,
`priority`, `nonce`, `ciphertext`, `createdAt`) are always present.
For system notifications, `nonce` and `ciphertext` are empty strings.
---
## Scheduled messages
Peers can schedule one-shot or recurring messages for future delivery.
When a scheduled message fires, the recipient receives a standard
`push` with `subtype: "reminder"`.
### `schedule` (peer → broker)
```jsonc
{
"type": "schedule",
"to": "<pubkey or display name>",
"message": "Stand-up in 5 minutes",
"deliverAt": 1717459200000,
"subtype": "reminder",
"cron": "0 9 * * 1-5",
"recurring": true
}
```
| Field | Type | Required | Description |
|-------------|--------------|----------|------------------------------------------------------------------|
| `to` | `string` | yes | Recipient — member pubkey or display name |
| `message` | `string` | yes | Plaintext message body |
| `deliverAt` | `number` | yes | Unix timestamp (ms). Ignored when `cron` is set. |
| `subtype` | `"reminder"` | no | Semantic tag — surfaces differently to the receiver |
| `cron` | `string` | no | Standard 5-field cron expression for recurring delivery |
| `recurring` | `boolean` | no | Whether this is a recurring schedule. Implied `true` when `cron` is set. |
### `scheduled_ack` (broker → peer)
```jsonc
{
"type": "scheduled_ack",
"scheduledId": "sched_abc",
"deliverAt": 1717459200000,
"cron": "0 9 * * 1-5"
}
```
| Field | Type | Required | Description |
|---------------|----------|----------|-------------------------------------------|
| `scheduledId` | `string` | yes | Assigned id for the scheduled entry |
| `deliverAt` | `number` | yes | Resolved delivery time (ms epoch) |
| `cron` | `string` | no | Echoed cron expression for recurring entries |
### `list_scheduled` (peer → broker)
No payload fields beyond `type`.
### `scheduled_list` (broker → peer)
```jsonc
{
"type": "scheduled_list",
"messages": [
{
"id": "sched_abc",
"to": "<pubkey>",
"message": "Stand-up in 5 minutes",
"deliverAt": 1717459200000,
"createdAt": 1717372800000,
"cron": "0 9 * * 1-5",
"firedCount": 3
}
]
}
```
| Field | Type | Required | Description |
|--------------|----------|----------|-----------------------------------------------|
| `id` | `string` | yes | Scheduled entry id |
| `to` | `string` | yes | Recipient |
| `message` | `string` | yes | Message body |
| `deliverAt` | `number` | yes | Next delivery time (ms epoch) |
| `createdAt` | `number` | yes | When the entry was created (ms epoch) |
| `cron` | `string` | no | Cron expression, present for recurring entries|
| `firedCount` | `number` | no | Times the cron entry has fired so far |
### `cancel_scheduled` (peer → broker)
| Field | Type | Required | Description |
|---------------|----------|----------|-----------------------------|
| `scheduledId` | `string` | yes | Id of the entry to cancel |
### `cancel_scheduled_ack` (broker → peer)
| Field | Type | Required | Description |
|---------------|-----------|----------|---------------------------------|
| `scheduledId` | `string` | yes | Echoed id |
| `ok` | `boolean` | yes | Whether cancellation succeeded |
---
## Crypto
- **Signing** — ed25519 (libsodium `crypto_sign`). One keypair per peer

315
docs/vision-20260407.md Normal file
View File

@@ -0,0 +1,315 @@
# claudemesh — Vision & Feature Brainstorm
**Date:** 2026-04-07 23:01 CEST
**Author:** Alejandro Gutiérrez + Claude (Opus 4.6)
**Status:** Internal brainstorm — not committed to public roadmap
**Last updated:** 2026-04-07 23:36 CEST
---
## Tier 1 — High impact, buildable now
### 1. Session path (pwd) sharing — DONE
Add `cwd` to the WS hello handshake. Broker stores it in the peer record, `list_peers` returns it. Peers on the same machine see each other's working directories — lets AI reference files across sessions without guessing paths.
**Effort:** 30 min. One field in hello + peer list.
> **Implemented:** 2026-04-07 23:30 · `810f372` · CLI 0.6.9 + broker deployed
### 2. Peer metadata: human vs AI, channel type, model — DONE
Extend the hello handshake with `peerType: "ai" | "human" | "connector"`, `channel?: "claude-code" | "telegram" | "slack" | "web"`, `model?: "opus-4" | "sonnet-4" | "gpt-5" | ...`. Broker stores and broadcasts it. `list_peers` shows it.
**Why:** Foundation for connectors, human peers, and smart routing (send complex analysis to the Opus peer, quick tasks to Sonnet).
**Effort:** 1 hour.
> **Implemented:** 2026-04-07 23:30 · `810f372` · Shipped with item 1 (same commit)
### 3. System notifications (join/leave/resource events) — DONE
Broker pushes system-level messages when peers connect/disconnect, files get shared, state changes, tasks get created. Same `subtype` pattern as reminders: `{ type: "push", subtype: "system", event: "peer_joined", ... }`.
**Why:** Mesh feels alive. AI can react to topology changes without polling.
**Effort:** 2 hours.
> **Implemented:** 2026-04-07 23:20 · `453705a` · peer_joined + peer_left broadcasts, system subtype in push
### 4. Cron-based reminders — DONE
Replace `setTimeout` with a persistent cron scheduler (broker-side). AI sends `schedule_reminder --cron "0 */2 * * *" --message "check deploy status"`. Broker uses `node-cron` or Drizzle-backed scheduler. Survives broker restarts.
**Why:** Current reminders die if the broker restarts. Cron syntax is already familiar to AI.
**Effort:** 2 hours (+ DB migration for persistence).
> **Implemented:** 2026-04-07 23:35 · `e873807` · DB-persisted schedules, zero-dep cron parser, restart recovery, `--cron` CLI flag
### 5. Heartbeats / session supervisor + simulation clock
**Keepalive layer:** WebSocket ping/pong for connection health. A CLI-side supervisor monitors the WS connection and relaunches Claude Code if it drops. Broker marks peers as disconnected on WS close.
**Simulation clock layer:** Heartbeats become a broker-driven clock that peers can subscribe to. The broker broadcasts periodic `{ subtype: "heartbeat", tick: 42, simTime: "2026-04-08T14:30:00Z", speed: "x10" }` messages at a configurable rate.
**Time multiplier for load testing:**
- `mesh_set_clock(speed: "x1")` — real-time, normal operation
- `mesh_set_clock(speed: "x10")` — 1 hour of simulated activity in 6 minutes
- `mesh_set_clock(speed: "x100")` — 1 day of simulated activity in ~15 minutes
**Use case — infrastructure stress testing:** Spawn 10 AI peers, each simulating a real user persona (sales rep, admin, customer). Set the clock to x10. Each peer receives heartbeat ticks and acts according to the simulated time: "it's 9am, log in and check dashboard", "it's 11am, process 5 orders", "it's 3pm, run reports". The infrastructure sees realistic usage patterns at 10x speed.
**What peers see:**
```
> mesh_clock()
Simulation clock: x10 | sim time: 2026-04-08 14:30 | tick: 42/480
> [heartbeat tick 43 — sim time: 14:36]
AI peer "Sales-Rep-1": creates 3 orders, searches inventory
AI peer "Admin-1": approves pending orders, checks stock levels
AI peer "Customer-1": browses catalog, adds to cart, checks out
```
**Components:**
- Broker: clock state + periodic broadcast to all peers
- MCP tools: `mesh_set_clock(speed)`, `mesh_clock()`, `mesh_pause_clock()`, `mesh_resume_clock()`
- Peer behavior: AI reads tick + simTime from heartbeat, decides actions based on its persona and the simulated time of day
- Reporting: broker collects action counts per tick, produces load profile after the run
**Why this is powerful:** Unlike synthetic load testers (k6, Locust), AI peers exercise the *full stack* — UI flows, API sequences, edge cases, realistic data entry. They find bugs that scripted tests miss because they improvise like real users.
**Effort:** 1 day (heartbeat + clock), 1 day (simulation framework + personas).
---
## Tier 2 — Strong ideas, needs design
### 6. Mesh webhooks / REST API / external WebSocket
Three surfaces for external integration:
- **Inbound webhooks:** `POST https://ic.claudemesh.com/hook/<mesh-id>/<secret>` → broker injects as a push to all peers or a specific group. GitHub, CI/CD, monitoring alerts become mesh messages.
- **REST API:** Authenticated endpoints to send messages, read state, list peers from outside. Makes the mesh programmable from any language.
- **External WS:** Non-Claude clients connect via WS with an API key (not a session keypair). Same protocol, different auth.
**Prerequisite:** API keys per mesh (not ephemeral session keypairs).
**Effort:** Half day (webhooks alone), 2-3 days (full API surface).
### 7. Connectors: Slack, Telegram as peers
**Approach 1 — Connector-as-peer (recommended start):** A bridge process joins the mesh as a peer named "Slack-#general" and relays messages bidirectionally. Peers see it in `list_peers` with `peerType: "connector"`. One connector per channel.
**Approach 2 — Connector-as-router:** Broker-level integration — messages to `#slack:general` route through a registered connector. More elegant, but complex.
Ship as `claudemesh-connector-slack`, `claudemesh-connector-telegram`.
**Effort:** 1-2 days each.
### 8. Humans in the mesh
Humans connect via the web dashboard or mobile app using the same WS protocol. `peerType: "human"` metadata tells AI to adjust communication style. The push system works natively in browsers (WS is bidirectional).
**Challenge:** UX. Humans need a chat interface with typing indicators, read receipts, message history — not raw JSON. The dashboard already exists at claudemesh.com; extend it with a chat panel.
**Effort:** 2-3 days (web chat panel).
### 9. Connecting non-Claude-Code AI
Any process that speaks the WS protocol can join. The barrier isn't the protocol — it's the MCP tool surface that makes Claude Code sessions first-class. For other LLMs:
- **SDK approach:** `npm install claudemesh-sdk` — a JS/Python library that handles WS connection, crypto, and message parsing. Wrap any LLM's function-calling interface around it.
- **Push delivery:** The push system works over WS. Non-Claude clients receive pushes the same way. The challenge is injecting them into the LLM's context — each platform has a different mechanism (OpenAI function results, Gemini tool responses, etc.).
- **Adapter pattern:** `claudemesh-adapter-openai`, `claudemesh-adapter-cursor`, etc.
**Effort:** 1 day (SDK), 1 day per adapter.
### 10. Mesh skills catalog
Peers publish skills: `share_skill({ name: "pdf-generation", description: "...", instructions: "..." })`. Other peers `list_skills()` and `get_skill("pdf-generation")` to load instructions into their context. Broker stores skills like memory/state.
**Why:** A mesh becomes a capability marketplace. One session installs a skill, all peers benefit. Skills can include tool definitions, system prompts, reference docs, and example workflows.
**This is the killer feature.** It turns claudemesh from a messaging layer into a knowledge-sharing platform.
**Effort:** 1 day.
### 11. Shared project files across peers
When a peer connects, it registers accessible paths (opt-in per directory). Other peers request files: `get_peer_file(peer: "Alice", path: "src/auth.ts")`. The owning peer reads the file and returns it over the mesh.
**Security scoping options:**
- Opt-in per directory: `claudemesh launch --share-dir ./src`
- Same-machine only (detect via hostname/IP)
- Approval per request
**Effort:** 1 day.
### 12. Peer stats (context consumption, token usage)
Peers self-report: `set_status` extended with `contextUsed: 85000, contextMax: 200000, tokensIn: 12000, tokensOut: 8000`. Dashboard shows burn rate. Useful for load balancing — route work to the peer with the most context headroom.
**Limitation:** Claude Code doesn't expose context usage via API. Would need estimation from conversation length or `/cost` command parsing.
**Effort:** Half day (reporting infrastructure), unknown (accurate context measurement).
---
## Tier 3 — Big bets, needs careful thought
### 13. Mesh blockchain / signed audit log
**Honest assessment:** A full blockchain is overkill for a cooperative mesh. What's actually valuable is the useful parts:
- **Signed append-only log:** Immutable record of all decisions, state changes, and messages. Merkle tree integrity. Useful for compliance, debugging, and "who decided what."
- **Conflict resolution:** Vector clocks or CRDTs for state, instead of last-write-wins.
- **Reputation:** Track which peers deliver on tasks, respond promptly, produce quality work.
**Reframe as:** Signed audit trail with integrity proofs. Not a blockchain, but the valuable properties of one.
**Effort:** 3-5 days.
### 14. Mesh of meshes / bridge
A meta-broker that routes between meshes. Use case: `dev-team` mesh and `ops-team` mesh coordinate on deploys.
**Simple version:** A bridge peer joins both meshes and relays tagged messages. No broker changes needed. Already feasible with today's protocol.
**Federation version:** Broker-to-broker peering protocol. Brokers exchange presence and route ciphertext across organizations.
**Effort:** 1 day (bridge peer), 1-2 weeks (federation protocol).
### 15. Mesh templates on creation — DONE
Predefined mesh configurations: roles, groups, state keys, system prompts, skills, and governance rules. Examples:
- `dev-team`: @frontend, @backend, @devops groups; lead/member roles; state keys for sprint/deploy-frozen
- `research`: @analysis, @writing groups; shared memory focus; context-sharing optimized
- `ops-incident`: @oncall, @comms groups; high-urgency defaults; auto-escalation rules
Templates are JSON files. `claudemesh create --template dev-team` applies them at mesh creation. Templates are editable post-creation by mesh admin (or anyone, depending on governance).
**Effort:** Half day.
> **Implemented:** 2026-04-07 23:25 · `69e93d4` · 5 templates (dev-team, research, ops-incident, simulation, personal) + `claudemesh create` command
### 16. Default private mesh per user
On `claudemesh install`, auto-create a personal mesh with the user as sole member. All their Claude Code sessions join by default. Zero-config — instant value without understanding meshes.
**Effort:** Half day.
### 17. Mesh MCP proxy (dynamic tools without session restart)
**Problem:** Claude Code loads MCP servers at startup. You can't inject new tool definitions into a running session.
**Solution:** Route through the existing claudemesh MCP connection. A generic `mesh_tool_call` tool proxies to MCP servers registered in the mesh at runtime — no restart needed.
**Flow:**
1. A peer registers an MCP server: `mesh_mcp_register(name: "github", transport: "stdio", command: "npx @github/mcp")`
2. Broker stores the registration
3. Any peer calls `mesh_tool_call(server: "github", tool: "list_repos", args: {...})`
4. Broker routes to the hosting peer or a shared sidecar process
5. That host invokes the actual MCP server, returns the result through the mesh
6. Calling peer gets the response — all through the existing claudemesh WS connection
**Two hosting models:**
- **Peer-hosted:** The registering peer runs the MCP server locally. Other peers proxy through them. If that peer disconnects, the MCP goes offline.
- **Broker-hosted:** The broker spawns the MCP server as a sidecar. Always available. Better for shared tools (database, GitHub, Jira).
**What AI sees:**
```
> mesh_mcp_list()
Available mesh MCP servers:
- github (hosted by: Alice) — tools: list_repos, create_issue, ...
- jira (hosted by: broker) — tools: search_issues, create_ticket, ...
- postgres-prod (hosted by: broker) — tools: query, execute
> mesh_tool_call(server: "github", tool: "create_issue", args: {repo: "...", title: "..."})
Issue #42 created.
```
**Limitation:** Claude Code won't see these as first-class tools in its tool list — AI needs to know to use `mesh_tool_call`. MCP server instructions document the proxy pattern.
**New MCP tools needed:** `mesh_mcp_register`, `mesh_mcp_list`, `mesh_tool_call`, `mesh_mcp_remove`
**Effort:** 2-3 days.
### 18. Sandbox for code execution
Each mesh gets optional compute sandboxes (Docker containers, Firecracker VMs, or E2B-style). Peers request: `execute_code(lang: "python", code: "...")`. Broker provisions a sandbox, runs the code, returns stdout/stderr. Resources scale on demand as peers need sandboxes.
**Build vs integrate:**
- **Build:** Docker-in-Docker on the broker host. Simple but security-sensitive.
- **Integrate:** E2B, Modal, or Fly Machines as the sandbox backend. claudemesh MCP tool is a thin client. Scales naturally.
**Effort:** 2-3 days (E2B integration), 1-2 weeks (self-hosted sandboxes).
### 19. Mesh dashboard (real-time situational awareness) — PARTIAL
Live web UI at claudemesh.com/dashboard showing:
- **Peer graph:** Who's connected, status, groups, roles — nodes and edges
- **Message flow:** Animated edges showing real-time traffic between peers
- **State/memory timeline:** When values changed and who changed them
- **Resource panel:** Files shared, tasks active, skills available
- **Peer detail:** Click a peer → see summary, context usage, message history
Broker already tracks everything needed. Dashboard subscribes via WS and renders with D3/React.
**Effort:** 2-3 days (functional), 1 week (polished).
> **Partial:** 2026-04-07 23:30 · `59332dc` · Peer graph component (radial SVG layout, animated edges, group rings) added to live dashboard page. Remaining: state/memory timeline, resource panel, peer detail view.
### 20. Peer visibility and spatial topology
Control which peers can see each other. Instead of a flat mesh where everyone sees everyone, the broker filters `list_peers` responses and message routing based on visibility rules.
**Three visibility models:**
- **Proximity-based (simulation):** Each peer has coordinates `(x, y)` and a visibility radius. Only peers within range appear in `list_peers`. `set_position(x, y)` changes who you can see — spatial fog of war. Combined with the simulation clock, this creates emergent behavior: a "customer" peer walks into a "store zone", suddenly sees "sales rep" peers, initiates interaction.
- **Scope-based (organizational):** Visibility follows group membership. Peers in `@frontend` see each other and `@leads`, but not `@backend` internals. Org-chart visibility without exposing every department.
- **Manual/dynamic:** Peers or admins explicitly show/hide. `set_visible(false)` to go stealth (connected but invisible). Admin can force visibility/invisibility.
**Who controls visibility:**
- **Broker rules** — mesh-wide policy set at creation or via template (e.g., "proximity" mode for simulations, "scope" for orgs)
- **Peer self-control** — `set_visible(false)` to go stealth, `set_position(x, y)` to move in proximity mode
- **Admin override** — mesh admin force-shows or force-hides peers
- **Dynamic conditions** — broker changes visibility based on state keys, clock ticks, or events
**Notifications:** Peers receive `{ subtype: "system", event: "peer_visible" }` when a new peer enters their visibility and `peer_hidden` when one leaves. Different from join/leave — the peer is still connected, just not visible to you.
**Peer public profile (outside image):** Each peer has a public-facing profile that other peers see — a curated view separate from internal state. Fields: `avatar` (emoji or URL), `title` (short role label), `bio` (one-liner), `capabilities` (what I can help with). Set via `set_profile({ avatar: "🔧", title: "DevOps Lead", bio: "Infrastructure and deploys" })`. This is what appears on the peer graph node and in `list_peers`. Peers choose how they present themselves to the mesh.
**MCP tools:** `set_visible(visible)`, `set_position(x, y)`, `set_profile(profile)`, `get_visible_peers()`, `set_visibility_mode(mode)` (admin only)
**Effort:** 2-3 days.
---
## Suggested build order
| # | Feature | Effort | Unlocks | Status |
|---|---------|--------|---------|--------|
| 1 | Session path sharing | 30 min | File referencing across sessions | **DONE** `810f372` |
| 2 | Peer metadata (type/channel/model) | 1 hour | Connectors, humans, smart routing | **DONE** `810f372` |
| 3 | System notifications | 2 hours | Reactive mesh, awareness | **DONE** `453705a` |
| 4 | Cron reminders | 2 hours | Persistent scheduling | **DONE** `e873807` |
| 5 | Mesh templates | Half day | Better onboarding | **DONE** `69e93d4` |
| 6 | Default personal mesh | Half day | Zero-config start | |
| 7 | Inbound webhooks | Half day | External integrations | |
| 8 | Skills catalog | 1 day | Knowledge marketplace | |
| 9 | Shared project files | 1 day | Cross-session file access | |
| 10 | Slack connector | 1-2 days | Reach beyond Claude Code | |
| 11 | Mesh MCP proxy | 2-3 days | Dynamic tools without restart | |
| 12 | Dashboard (real-time) | 2-3 days | Visual situational awareness | **PARTIAL** `59332dc` |
| 13 | Human peers (web chat) | 2-3 days | Humans in the loop | |
| 14 | Simulation clock (heartbeat x1-x100) | 2 days | AI-driven load testing | |
| 15 | Sandboxes (E2B) | 2-3 days | Shared compute | |
| 16 | Signed audit log | 3-5 days | Trust, compliance | |
| 17 | Bridge / federation | 1-2 weeks | Multi-mesh coordination | |
| 18 | Peer visibility + spatial topology | 2-3 days | Simulation fog-of-war, org scoping | |
---
*This document captures a brainstorming session. Items are not commitments. Priorities will shift as we build and learn.*

View File

@@ -30,14 +30,16 @@ The work doubles. The context dies on every restart.
## What claudemesh does
claudemesh is a self-hosted broker that connects Claude Code sessions across machines into one live mesh.
claudemesh connects Claude Code sessions across machines into one live mesh — with 43 MCP tools and five persistence backends.
- Every session announces what it is working on.
- Any session can message another — by human name, by repo, by machine.
- Messages route through a local WebSocket broker you run yourself.
- Presence, priority, and status are tracked automatically from each session's activity.
- **Messaging:** Send by name, @group, or broadcast. Three priority tiers. E2E encrypted (crypto_box). Scheduled messages and reminders.
- **Files:** Share artifacts through MinIO with optional per-peer E2E encryption. Grant access later. Audit trail.
- **Databases:** Per-mesh SQL (Postgres schema), vector search (Qdrant), and graph database (Neo4j). Agents create tables, store embeddings, and run Cypher queries.
- **State & Memory:** Shared key-value state with instant push. Full-text searchable memory that survives across sessions.
- **Streams & Tasks:** Real-time pub/sub data streams. Lightweight task board with claim/complete workflow.
- **Presence:** Status detected automatically from Claude Code hooks. Three-source priority model. DND gates.
No cloud account. No training on your code. Your mesh, your machines, your rules.
No training on your code. The broker routes ciphertext — it never reads your messages.
---
@@ -67,11 +69,12 @@ Release Claude opens a PR. Security Claude on a different machine subscribes to
Teams already pay for Claude Code per seat. claudemesh multiplies what those seats do together.
- **Context survives handoffs.** One agent hands work to the next with full history. No rebuilding.
- **Context survives handoffs.** Shared memory, files, and databases carry forward. No rebuilding.
- **Decisions stay in the tool.** No copy-paste into Slack, Jira, or a meeting that did not need to happen.
- **Work parallelises.** Six agents on six machines can coordinate on the same release without humans playing telephone.
- **Your data stays local.** Self-hosted broker. Messages never leave your network.
- **Audit trail by default.** Every message, every status, every handoff, logged.
- **Work parallelises.** Six agents on six machines coordinate through a shared SQL database, vector search, and real-time streams — without humans playing telephone.
- **Your data stays encrypted.** E2E crypto_box on messages and files. The broker routes ciphertext.
- **Five persistence layers.** KV state, full-text memory, SQL, vectors, graphs — agents pick the right tool.
- **Audit trail by default.** Every message, every status, every file access, logged.
claudemesh does not replace the engineer. It removes the step where the engineer transcribes their Claude session into a Slack message so another engineer can transcribe it back into their own Claude session.

View File

@@ -47,16 +47,50 @@
"duckdb",
"better-sqlite3",
"sharp"
],
"overrides": {
"csstype": "3.1.3",
"@types/react": "19.2.7"
}
]
},
"engines": {
"node": ">=22.17.0"
},
"dependencies": {
"react": "19.2.3"
},
"overrides": {
"csstype": "3.1.3",
"@types/react": "19.2.7"
},
"workspaces": {
"packages": [
"apps/*",
"packages/**",
"tooling/*"
],
"catalog": {
"@tanstack/react-query": "5.90.6",
"@tanstack/react-query-devtools": "5.90.2",
"@tanstack/react-table": "8.21.3",
"@vitest/coverage-v8": "4.0.14",
"@ai-sdk/react": "2.0.94",
"ai": "5.0.94",
"envin": "1.1.10",
"eslint": "9.39.0",
"prettier": "3.6.2",
"react-hook-form": "7.66.0",
"react-native": "0.81.5",
"typescript": "5.9.3",
"vitest": "4.0.14",
"zod": "4.1.13"
},
"catalogs": {
"node22": {
"@types/node": "22.16.0"
},
"react19": {
"@types/react": "19.1.14",
"@types/react-dom": "19.1.9",
"react": "19.1.0",
"react-dom": "19.1.0"
}
}
}
}

View File

@@ -0,0 +1,13 @@
ALTER TABLE "mesh"."file" ADD COLUMN "encrypted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD COLUMN "owner_pubkey" text;--> statement-breakpoint
CREATE TABLE "mesh"."file_key" (
"id" text PRIMARY KEY NOT NULL,
"file_id" text NOT NULL,
"peer_pubkey" text NOT NULL,
"sealed_key" text NOT NULL,
"granted_at" timestamp DEFAULT now() NOT NULL,
"granted_by_pubkey" text
);
--> statement-breakpoint
ALTER TABLE "mesh"."file_key" ADD CONSTRAINT "file_key_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "file_key_file_peer_idx" ON "mesh"."file_key" ("file_id","peer_pubkey");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "mesh"."context" ADD COLUMN "member_id" text;--> statement-breakpoint
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_member_id_member_id_fk" FOREIGN KEY ("member_id") REFERENCES "mesh"."member"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "context_mesh_member_idx" ON "mesh"."context" ("mesh_id","member_id");

View File

@@ -305,6 +305,8 @@ export const meshFile = meshSchema.table("file", {
minioKey: text().notNull(),
tags: text().array().default([]),
persistent: boolean().notNull().default(true),
encrypted: boolean().notNull().default(false),
ownerPubkey: text(),
uploadedByName: text(),
uploadedByMember: text().references(() => meshMember.id),
targetSpec: text(), // null = entire mesh
@@ -328,24 +330,60 @@ export const meshFileAccess = meshSchema.table("file_access", {
});
/**
* Per-peer context snapshot. Each peer (presence) has at most one context
* Per-peer encrypted symmetric keys for E2E encrypted files.
* The file body is encrypted with a random key (Kf); Kf is sealed
* (crypto_box_seal) to each authorized peer's X25519 pubkey and stored here.
*/
export const meshFileKey = meshSchema.table("file_key", {
id: text().primaryKey().notNull().$defaultFn(generateId),
fileId: text()
.references(() => meshFile.id, { onDelete: "cascade" })
.notNull(),
peerPubkey: text().notNull(),
sealedKey: text().notNull(),
grantedAt: timestamp().defaultNow().notNull(),
grantedByPubkey: text(),
});
export const meshFileKeyRelations = relations(meshFileKey, ({ one }) => ({
file: one(meshFile, {
fields: [meshFileKey.fileId],
references: [meshFile.id],
}),
}));
/**
* Per-peer context snapshot. Each peer (member) has at most one context
* entry per mesh, upserted on each share_context call. Allows peers to
* discover what others are working on, which files they've read, and
* key findings — without sending a direct message.
*
* `memberId` is the stable upsert key (survives reconnects). `presenceId`
* is kept for backwards-compat but is nullable — new rows should always
* populate `memberId`. The unique index on (meshId, memberId) prevents
* stale rows from accumulating when a session reconnects with a new
* ephemeral presenceId.
*/
export const meshContext = meshSchema.table("context", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
peerName: text(),
summary: text().notNull(),
filesRead: text().array().default([]),
keyFindings: text().array().default([]),
tags: text().array().default([]),
updatedAt: timestamp().defaultNow().notNull(),
});
export const meshContext = meshSchema.table(
"context",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
memberId: text().references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }),
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
peerName: text(),
summary: text().notNull(),
filesRead: text().array().default([]),
keyFindings: text().array().default([]),
tags: text().array().default([]),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [
uniqueIndex("context_mesh_member_idx").on(table.meshId, table.memberId),
],
);
/**
* Mesh-scoped task board. Peers can create tasks, claim them, and mark
@@ -389,6 +427,50 @@ export const meshStream = meshSchema.table(
(table) => [uniqueIndex("stream_mesh_name_idx").on(table.meshId, table.name)],
);
/**
* Persistent scheduled messages. Survives broker restarts — on boot the
* broker loads all non-cancelled, non-expired rows and re-arms timers.
* Supports both one-shot (deliverAt) and recurring (cron expression).
*/
export const scheduledMessage = meshSchema.table("scheduled_message", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
/** Nullable — the presence that created it may be gone after a restart. */
presenceId: text(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
to: text().notNull(),
message: text().notNull(),
/** Unix timestamp (ms) for one-shot delivery. Null for cron-only entries. */
deliverAt: timestamp(),
/** 5-field cron expression for recurring delivery. Null for one-shot. */
cron: text(),
subtype: text(),
firedCount: integer().notNull().default(0),
cancelled: boolean().notNull().default(false),
firedAt: timestamp(),
createdAt: timestamp().defaultNow().notNull(),
});
export const scheduledMessageRelations = relations(scheduledMessage, ({ one }) => ({
mesh: one(mesh, {
fields: [scheduledMessage.meshId],
references: [mesh.id],
}),
member: one(meshMember, {
fields: [scheduledMessage.memberId],
references: [meshMember.id],
}),
}));
export const selectScheduledMessageSchema = createSelectSchema(scheduledMessage);
export const insertScheduledMessageSchema = createInsertSchema(scheduledMessage);
export type SelectScheduledMessage = typeof scheduledMessage.$inferSelect;
export type InsertScheduledMessage = typeof scheduledMessage.$inferInsert;
export const meshRelations = relations(mesh, ({ one, many }) => ({
owner: one(user, {
fields: [mesh.ownerUserId],
@@ -531,6 +613,10 @@ export type SelectMeshFile = typeof meshFile.$inferSelect;
export type InsertMeshFile = typeof meshFile.$inferInsert;
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;
export const selectMeshFileKeySchema = createSelectSchema(meshFileKey);
export const insertMeshFileKeySchema = createInsertSchema(meshFileKey);
export type SelectMeshFileKey = typeof meshFileKey.$inferSelect;
export type InsertMeshFileKey = typeof meshFileKey.$inferInsert;
export const selectMeshContextSchema = createSelectSchema(meshContext);
export const insertMeshContextSchema = createInsertSchema(meshContext);
export const selectMeshTaskSchema = createSelectSchema(meshTask);

782
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff