31 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
5c62d287cf fix(cli): v0.5.4 — revert to event-driven push, add Claude Code integration spec
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Revert poll-based drain (v0.5.2 overcorrection). Claude Code source
confirms notifications are processed event-driven via React
useEffect, not polled. The WS onPush → server.notification() path
is correct.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:04:05 +01:00
Alejandro Gutiérrez
9ae378c2e3 fix(cli): v0.5.3 — add push delivery debug logging
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:49:49 +01:00
Alejandro Gutiérrez
7381738f0b fix(web): disable turbopack for prod build (payload CSS compat)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:46:28 +01:00
Alejandro Gutiérrez
8c6b0c0e07 fix(cli): v0.5.2 — poll-based push delivery (1s interval)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Replace WS onPush→notification with timer-based buffer drain.
The old claude-intercom used 1s polling and worked reliably.
WS async callbacks may not flush stdio properly for MCP
notifications. Polling on a timer ensures consistent delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:33:26 +01:00
Alejandro Gutiérrez
ec9626503c fix(web): force-dynamic on payload admin page (build CSS error)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:16:21 +01:00
Alejandro Gutiérrez
820ec085b2 feat(cli): v0.5.1 — message modes (push/inbox/off)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
--inbox: count-only notifications, no content in context
--no-messages: tools only, zero prompt injection risk
Default: push (real-time, current behavior)

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:53:41 +01:00
Alejandro Gutiérrez
9e6f6d7bc9 docs: add message modes + shared MCPs spec
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Message modes: push/inbox/off for controlling prompt injection risk.
Shared MCPs: mesh-level MCP servers proxied through the broker —
install once, every peer has access. Full architecture, DB schema,
WS protocol, credential isolation, resource limits.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:03:10 +01:00
Alejandro Gutiérrez
cc6e56aef9 docs: final spec — vectors, graph, context, tasks, streams, databases
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Full vision: claudemesh provisions shared infrastructure per mesh.
Peers share messages, state, memory, files, vector embeddings,
entity graphs, session context, tasks, structured databases, and
real-time streams. All through MCP tools, zero configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:00:50 +01:00
Alejandro Gutiérrez
1aaa483d60 feat: v0.4.0 — File sharing + multi-target messages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:56:01 +01:00
Alejandro Gutiérrez
99d9d19079 docs: update spec with files, multi-target, views, infra vision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:48:32 +01:00
Alejandro Gutiérrez
888078876a feat: v0.3.0 — State, Memory, message_status, MCP instructions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Phase B + C + message delivery status.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:02:44 +01:00
Alejandro Gutiérrez
aaab7feea6 fix(web): restore turbopack SVG loader (fixes React #130)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The turbopack.rules config for @svgr/webpack was removed during
the Payload integration attempts. Without it, SVG imports return
raw module objects instead of React components. This crashes
LocaleCustomizer → Icons.UnitedKingdom → object → React #130.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:48:36 +01:00
Alejandro Gutiérrez
af13125424 chore(web): restore next.js 16.2.2 (React #130 is pre-existing)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The hydration crash exists on both 16.0.10 and 16.2.2 — it's a
pre-existing component bug, not a Next.js regression. Stay on
latest for security + Payload compat when we re-add it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:38:22 +01:00
54 changed files with 31689 additions and 841 deletions

1024
SPEC.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,14 @@ const envSchema = z.object({
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.coerce.boolean().default(false),
QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"),
NEO4J_PASSWORD: z.string().default("changeme"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

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

View File

@@ -52,9 +52,13 @@ export interface WSHelloMessage {
meshId: string;
memberId: string;
pubkey: string; // must match mesh.member.peerPubkey
sessionPubkey?: string; // ephemeral per-launch pubkey for message routing
displayName?: string; // optional override for this session
sessionId: string;
pid: number;
cwd: string;
/** Initial groups to join on connect. */
groups?: Array<{ name: string; role?: string }>;
/** ms epoch; broker rejects if outside ±60s of its own clock. */
timestamp: number;
/** ed25519 signature (hex) over the canonical hello bytes:
@@ -101,6 +105,56 @@ export interface WSSetSummaryMessage {
summary: string;
}
/** Client → broker: join a group with optional role. */
export interface WSJoinGroupMessage {
type: "join_group";
name: string;
role?: string;
}
/** Client → broker: leave a group. */
export interface WSLeaveGroupMessage {
type: "leave_group";
name: string;
}
/** Client → broker: set a shared state key-value. */
export interface WSSetStateMessage {
type: "set_state";
key: string;
value: unknown;
}
/** Client → broker: read a shared state key. */
export interface WSGetStateMessage {
type: "get_state";
key: string;
}
/** Client → broker: list all shared state entries. */
export interface WSListStateMessage {
type: "list_state";
}
/** Client → broker: store a memory. */
export interface WSRememberMessage {
type: "remember";
content: string;
tags?: string[];
}
/** Client → broker: full-text search memories. */
export interface WSRecallMessage {
type: "recall";
query: string;
}
/** Client → broker: soft-delete a memory. */
export interface WSForgetMessage {
type: "forget";
memoryId: string;
}
/** Broker → client: acknowledgement for a send. */
export interface WSAckMessage {
type: "ack";
@@ -124,11 +178,428 @@ export interface WSPeersListMessage {
displayName: string;
status: PeerStatus;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
}>;
}
/** Broker → client: a state key was changed by another peer. */
export interface WSStateChangeMessage {
type: "state_change";
key: string;
value: unknown;
updatedBy: string;
}
/** Broker → client: response to get_state. */
export interface WSStateResultMessage {
type: "state_result";
key: string;
value: unknown;
updatedAt: string;
updatedBy: string;
}
/** Broker → client: response to list_state. */
export interface WSStateListMessage {
type: "state_list";
entries: Array<{
key: string;
value: unknown;
updatedBy: string;
updatedAt: string;
}>;
}
/** Broker → client: acknowledgement for a remember. */
export interface WSMemoryStoredMessage {
type: "memory_stored";
id: string;
}
/** Broker → client: response to recall. */
export interface WSMemoryResultsMessage {
type: "memory_results";
memories: Array<{
id: string;
content: string;
tags: string[];
rememberedBy: string;
rememberedAt: string;
}>;
}
// --- Vector storage messages ---
/** Client → broker: store a text document in a vector collection. */
export interface WSVectorStoreMessage {
type: "vector_store";
collection: string;
text: string;
metadata?: Record<string, unknown>;
}
/** Client → broker: search a vector collection. */
export interface WSVectorSearchMessage {
type: "vector_search";
collection: string;
query: string;
limit?: number;
}
/** Client → broker: delete a point from a vector collection. */
export interface WSVectorDeleteMessage {
type: "vector_delete";
collection: string;
id: string;
}
/** Client → broker: list all vector collections for this mesh. */
export interface WSListCollectionsMessage {
type: "list_collections";
}
// --- Graph database messages ---
/** Client → broker: run a read-only Cypher query. */
export interface WSGraphQueryMessage {
type: "graph_query";
cypher: string;
}
/** Client → broker: run a write Cypher statement. */
export interface WSGraphExecuteMessage {
type: "graph_execute";
cypher: string;
}
// --- Mesh database (per-mesh PostgreSQL schema) messages ---
/** Client → broker: run a SELECT query in the mesh's schema. */
export interface WSMeshQueryMessage {
type: "mesh_query";
sql: string;
}
/** Client → broker: run a DDL/DML statement in the mesh's schema. */
export interface WSMeshExecuteMessage {
type: "mesh_execute";
sql: string;
}
/** Client → broker: list tables and columns in the mesh's schema. */
export interface WSMeshSchemaMessage {
type: "mesh_schema";
}
// --- Vector/Graph response messages ---
/** Broker → client: vector search results. */
export interface WSVectorResultsMessage {
type: "vector_results";
results: Array<{
id: string;
text: string;
score: number;
metadata?: Record<string, unknown>;
}>;
}
/** Broker → client: list of vector collections. */
export interface WSCollectionListMessage {
type: "collection_list";
collections: string[];
}
/** Broker → client: graph query results. */
export interface WSGraphResultMessage {
type: "graph_result";
records: Array<Record<string, unknown>>;
}
/** Broker → client: mesh SQL query results. */
export interface WSMeshQueryResultMessage {
type: "mesh_query_result";
columns: string[];
rows: Array<Record<string, unknown>>;
rowCount: number;
}
/** Broker → client: mesh schema introspection results. */
export interface WSMeshSchemaResultMessage {
type: "mesh_schema_result";
tables: Array<{
name: string;
columns: Array<{ name: string; type: string; nullable: boolean }>;
}>;
}
/** Client → broker: get full mesh overview. */
export interface WSMeshInfoMessage {
type: "mesh_info";
}
/** Broker → client: aggregated mesh overview. */
export interface WSMeshInfoResultMessage {
type: "mesh_info_result";
mesh: string;
peers: number;
groups: string[];
stateKeys: string[];
memoryCount: number;
fileCount: number;
tasks: { open: number; claimed: number; done: number };
streams: string[];
tables: string[];
collections: string[];
yourName: string;
yourGroups: Array<{ name: string; role?: string }>;
}
/** Client → broker: check delivery status of a message. */
export interface WSMessageStatusMessage {
type: "message_status";
messageId: string;
}
/** Broker → client: delivery status with per-recipient detail. */
export interface WSMessageStatusResultMessage {
type: "message_status_result";
messageId: string;
targetSpec: string;
delivered: boolean;
deliveredAt: string | null;
recipients: Array<{
name: string;
pubkey: string;
status: "delivered" | "held" | "disconnected";
}>;
}
// --- File sharing messages ---
/** Client → broker: get a presigned download URL for a file. */
export interface WSGetFileMessage {
type: "get_file";
fileId: string;
}
/** Client → broker: list files in the mesh. */
export interface WSListFilesMessage {
type: "list_files";
query?: string;
from?: string;
}
/** Client → broker: get access log for a file. */
export interface WSFileStatusMessage {
type: "file_status";
fileId: string;
}
/** Client → broker: soft-delete a file. */
export interface WSDeleteFileMessage {
type: "delete_file";
fileId: string;
}
/** Broker → client: presigned URL for downloading a file. */
export interface WSFileUrlMessage {
type: "file_url";
fileId: string;
url: string;
name: string;
}
/** Broker → client: list of files in the mesh. */
export interface WSFileListMessage {
type: "file_list";
files: Array<{
id: string;
name: string;
size: number;
tags: string[];
uploadedBy: string;
uploadedAt: string;
persistent: boolean;
}>;
}
/** Broker → client: access log for a file. */
export interface WSFileStatusResultMessage {
type: "file_status_result";
fileId: string;
accesses: Array<{
peerName: string;
accessedAt: string;
}>;
}
// --- Context sharing messages ---
/** Client → broker: share current working context. */
export interface WSShareContextMessage {
type: "share_context";
summary: string;
filesRead?: string[];
keyFindings?: string[];
tags?: string[];
}
/** Client → broker: search contexts by query. */
export interface WSGetContextMessage {
type: "get_context";
query: string;
}
/** Client → broker: list all contexts in the mesh. */
export interface WSListContextsMessage {
type: "list_contexts";
}
/** Broker → client: acknowledgement for share_context. */
export interface WSContextSharedMessage {
type: "context_shared";
id: string;
}
/** Broker → client: response to get_context. */
export interface WSContextResultsMessage {
type: "context_results";
contexts: Array<{
peerName: string;
summary: string;
filesRead: string[];
keyFindings: string[];
tags: string[];
updatedAt: string;
}>;
}
/** Broker → client: response to list_contexts. */
export interface WSContextListMessage {
type: "context_list";
contexts: Array<{
peerName: string;
summary: string;
tags: string[];
updatedAt: string;
}>;
}
// --- Task messages ---
/** Client → broker: create a task. */
export interface WSCreateTaskMessage {
type: "create_task";
title: string;
assignee?: string;
priority?: string;
tags?: string[];
}
/** Client → broker: claim an open task. */
export interface WSClaimTaskMessage {
type: "claim_task";
taskId: string;
}
/** Client → broker: mark a task as done. */
export interface WSCompleteTaskMessage {
type: "complete_task";
taskId: string;
result?: string;
}
/** Client → broker: list tasks with optional filters. */
export interface WSListTasksMessage {
type: "list_tasks";
status?: string;
assignee?: string;
}
/** Broker → client: acknowledgement for create_task. */
export interface WSTaskCreatedMessage {
type: "task_created";
id: string;
}
/** Broker → client: response to list_tasks, claim_task, complete_task. */
export interface WSTaskListMessage {
type: "task_list";
tasks: Array<{
id: string;
title: string;
assignee: string | null;
claimedBy: string | null;
status: string;
priority: string;
createdBy: string | null;
tags: string[];
createdAt: string;
}>;
}
// --- Stream messages ---
/** Client → broker: create a named real-time stream. */
export interface WSCreateStreamMessage {
type: "create_stream";
name: string;
}
/** Client → broker: publish data to a stream. */
export interface WSPublishMessage {
type: "publish";
stream: string;
data: unknown;
}
/** Client → broker: subscribe to a stream. */
export interface WSSubscribeMessage {
type: "subscribe";
stream: string;
}
/** Client → broker: unsubscribe from a stream. */
export interface WSUnsubscribeMessage {
type: "unsubscribe";
stream: string;
}
/** Client → broker: list all streams in the mesh. */
export interface WSListStreamsMessage {
type: "list_streams";
}
/** Broker → client: acknowledgement for create_stream. */
export interface WSStreamCreatedMessage {
type: "stream_created";
id: string;
name: string;
}
/** Broker → client: real-time data pushed from a stream. */
export interface WSStreamDataMessage {
type: "stream_data";
stream: string;
data: unknown;
publishedBy: string;
}
/** Broker → client: response to list_streams. */
export interface WSStreamListMessage {
type: "stream_list";
streams: Array<{
id: string;
name: string;
createdBy: string;
createdAt: string;
subscriberCount: number;
}>;
}
/** Broker → client: structured error. */
export interface WSErrorMessage {
type: "error";
@@ -142,11 +613,69 @@ export type WSClientMessage =
| WSSendMessage
| WSSetStatusMessage
| WSListPeersMessage
| WSSetSummaryMessage;
| WSSetSummaryMessage
| WSJoinGroupMessage
| WSLeaveGroupMessage
| WSSetStateMessage
| WSGetStateMessage
| WSListStateMessage
| WSRememberMessage
| WSRecallMessage
| WSForgetMessage
| WSMessageStatusMessage
| WSGetFileMessage
| WSListFilesMessage
| WSFileStatusMessage
| WSDeleteFileMessage
| WSShareContextMessage
| WSGetContextMessage
| WSListContextsMessage
| WSCreateTaskMessage
| WSClaimTaskMessage
| WSCompleteTaskMessage
| WSListTasksMessage
| WSVectorStoreMessage
| WSVectorSearchMessage
| WSVectorDeleteMessage
| WSListCollectionsMessage
| WSGraphQueryMessage
| WSGraphExecuteMessage
| WSMeshQueryMessage
| WSMeshExecuteMessage
| WSMeshSchemaMessage
| WSCreateStreamMessage
| WSPublishMessage
| WSSubscribeMessage
| WSUnsubscribeMessage
| WSListStreamsMessage
| WSMeshInfoMessage;
export type WSServerMessage =
| WSHelloAckMessage
| WSPushMessage
| WSAckMessage
| WSPeersListMessage
| WSStateChangeMessage
| WSStateResultMessage
| WSStateListMessage
| WSMemoryStoredMessage
| WSMemoryResultsMessage
| WSMessageStatusResultMessage
| WSFileUrlMessage
| WSFileListMessage
| WSFileStatusResultMessage
| WSContextSharedMessage
| WSContextResultsMessage
| WSContextListMessage
| WSTaskCreatedMessage
| WSTaskListMessage
| WSVectorResultsMessage
| WSCollectionListMessage
| WSGraphResultMessage
| WSMeshQueryResultMessage
| WSMeshSchemaResultMessage
| WSStreamCreatedMessage
| WSStreamDataMessage
| WSStreamListMessage
| WSMeshInfoResultMessage
| WSErrorMessage;

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "0.1.5",
"version": "0.5.4",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [
"claude-code",

View File

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

View File

@@ -1,82 +1,384 @@
/**
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the
* claudemesh MCP server's `notifications/claude/channel` pushes get
* injected as system reminders mid-turn.
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Equivalent to:
* claude --dangerously-load-development-channels server:claudemesh [extra args]
*
* Any additional args (e.g. --model opus, --resume, -c) are passed
* through verbatim. Use --quiet to skip the informational banner.
* Flow:
* 1. Parse --name, --join, --mesh, --quiet flags
* 2. If --join: run join flow first (accepts token or URL)
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
import { tmpdir, hostname } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
function printBanner(): void {
// --- 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;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
// --- Group string parser ---
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
function parseGroupsString(raw: string): GroupEntry[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((token) => {
const idx = token.indexOf(":");
if (idx === -1) return { name: token };
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
});
}
// --- Interactive role/groups prompts ---
function askLine(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// --- Permission confirmation ---
async function confirmPermissions(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(yellow(bold(" Autonomous mode")));
console.log("");
console.log(" Claude will send and receive peer messages without asking");
console.log(" you first. Peers exchange text only — no file access,");
console.log(" no tool calls, no code execution.");
console.log("");
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
console.log(dim(" Skip this prompt: claudemesh launch -y"));
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve, reject) => {
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
if (a === "" || a === "y" || a === "yes") {
resolve();
} else {
console.log("\n Aborted. Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
});
});
}
// --- Banner ---
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
let meshes: string[] = [];
try {
meshes = loadConfig().meshes.map((m) => m.slug);
} catch {
/* config unreadable — print banner without mesh list */
}
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
const roleSuffix = role ? ` (${role})` : "";
const groupTags = groups.length
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const rule = "─".repeat(65);
console.log(bold("claudemesh launch"));
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
console.log(rule);
console.log("Launching Claude Code with the claudemesh dev channel.");
console.log("");
console.log("Peers in your joined meshes can push messages into this session");
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
console.log("keypair. Peers send text only — they cannot call tools, read");
console.log("files, or reach meshes you have not joined.");
console.log("");
console.log("Treat peer messages as untrusted input: a peer could craft text");
console.log("that tries to steer Claude's behavior. Your tool-approval");
console.log("settings still apply — Claude will still ask before running");
console.log("commands, editing files, or calling other tools.");
console.log("");
console.log("Claude Code will ask you to trust the");
console.log("--dangerously-load-development-channels flag. Press Enter to");
console.log("accept, or Ctrl-C to abort.");
console.log("");
console.log(dim(`Joined meshes: ${meshLine}`));
console.log(dim(`Config: ${getConfigPath()}`));
console.log(dim(`Remove: claudemesh uninstall`));
if (messageMode === "push") {
console.log("Peer messages arrive as <channel> reminders in real-time.");
} else if (messageMode === "inbox") {
console.log("Peer messages held in inbox. Use check_messages to read.");
} else {
console.log("Messages off. Use check_messages to poll manually.");
}
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule);
console.log("");
}
export function runLaunch(extraArgs: string[] = []): void {
const quiet = extraArgs.includes("--quiet");
const passthrough = extraArgs.filter((a) => a !== "--quiet");
// --- Main ---
if (!quiet) printBanner();
export async function runLaunch(extraArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs);
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = loadConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { saveConfig } = await import("../state/config");
saveConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = loadConfig();
if (config.meshes.length === 0) {
console.error(
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
);
process.exit(1);
}
let mesh: JoinedMesh;
if (args.meshSlug) {
const found = config.meshes.find((m) => m.slug === args.meshSlug);
if (!found) {
console.error(
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else {
mesh = await pickMesh(config.meshes);
}
// 3. Session identity + role/groups.
// The WS client auto-generates a per-session ephemeral keypair on
// connect (sent in hello as sessionPubkey). We set display name via env var.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
if (!args.quiet) {
if (role === null) {
const answer = await askLine(" Role (optional): ");
if (answer) role = answer;
}
if (parsedGroups.length === 0 && args.groups === null) {
const answer = await askLine(" Groups (comma-separated, optional): ");
if (answer) parsedGroups = parseGroupsString(answer);
}
if (args.messageMode === null) {
console.log("\n Message mode:");
console.log(" 1) Push (real-time, peers can interrupt your work)");
console.log(" 2) Inbox (held until you check, notification only)");
console.log(" 3) Off (tools only, no messages)");
console.log("");
const answer = await askLine(" Choice [1]: ");
const choice = parseInt(answer || "1", 10);
if (choice === 2) messageMode = "inbox";
else if (choice === 3) messageMode = "off";
else messageMode = "push";
}
if (role || parsedGroups.length) console.log("");
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
const tmpBase = tmpdir();
try {
for (const entry of readdirSync(tmpBase)) {
if (!entry.startsWith("claudemesh-")) continue;
const full = join(tmpBase, entry);
const age = Date.now() - statSync(full).mtimeMs;
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
}
} catch { /* best effort */ }
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
displayName,
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Banner + permission confirmation.
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
// Auto-permissions confirmation — needed for autonomous peer messaging.
if (!args.skipPermConfirm) {
await confirmPermissions();
}
}
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
// Strip any user-supplied --dangerously flags to avoid duplicates.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
continue;
}
filtered.push(args.claudeArgs[i]!);
}
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...passthrough,
"--dangerously-skip-permissions",
...filtered,
];
// Windows: npm global binaries are .cmd shims. Node's spawn without
// shell:true does not resolve PATHEXT, so we need shell:true on win32
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
const isWindows = process.platform === "win32";
const child = spawn("claude", claudeArgs, {
stdio: "inherit",
shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
},
});
// 7. Cleanup on exit.
const cleanup = (): void => {
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
};
child.on("error", (err: NodeJS.ErrnoException) => {
cleanup();
if (err.code === "ENOENT") {
console.error(
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code",
"✗ `claude` not found on PATH. Install Claude Code first.",
);
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
@@ -85,10 +387,15 @@ export function runLaunch(extraArgs: string[] = []): void {
});
child.on("exit", (code, signal) => {
cleanup();
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
// Cleanup on parent signals too.
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
process.on("SIGINT", () => { cleanup(); process.exit(0); });
}

View File

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

View File

@@ -30,9 +30,12 @@ Commands:
install Register MCP + Stop/UserPromptSubmit status hooks
(add --no-hooks for bare MCP registration)
uninstall Remove MCP server + hooks
launch [args] Launch Claude Code with real-time push messages enabled
(add --quiet to skip the info banner; passes through
extra flags, e.g. --model, --resume)
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
@@ -67,7 +70,7 @@ async function main(): Promise<void> {
await runHook(args);
return;
case "launch":
runLaunch(args);
await runLaunch(args);
return;
case "join":
await runJoin(args);

View File

@@ -5,22 +5,19 @@
* verification and one-time-use invite-token tracking land in Step 18.
*/
import { z } from "zod";
import { ensureSodium } from "../crypto/keypair";
const invitePayloadSchema = z.object({
v: z.literal(1),
mesh_id: z.string().min(1),
mesh_slug: z.string().min(1),
broker_url: z.string().min(1),
expires_at: z.number().int().positive(),
mesh_root_key: z.string().min(1),
role: z.enum(["admin", "member"]),
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
signature: z.string().regex(/^[0-9a-f]{128}$/i),
});
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
export interface InvitePayload {
v: 1;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
signature: string;
}
export interface ParsedInvite {
payload: InvitePayload;
@@ -28,6 +25,21 @@ export interface ParsedInvite {
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
}
function validatePayload(obj: unknown): InvitePayload {
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
const o = obj as Record<string, unknown>;
if (o.v !== 1) throw new Error("invite payload: v must be 1");
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
return o as unknown as InvitePayload;
}
/** Canonical invite bytes — must match broker's canonicalInvite(). */
export function canonicalInvite(p: {
v: number;
@@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
);
}
const parsed = invitePayloadSchema.safeParse(obj);
if (!parsed.success) {
throw new Error(
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
);
}
const payload = validatePayload(obj);
// Expiry check (unix seconds).
const nowSeconds = Math.floor(Date.now() / 1000);
if (parsed.data.expires_at < nowSeconds) {
if (payload.expires_at < nowSeconds) {
throw new Error(
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
);
}
// Verify the ed25519 signature against the embedded owner_pubkey.
// Client-side verification gives immediate feedback on tampered
// links; broker re-verifies authoritatively on /join.
const s = await ensureSodium();
const canonical = canonicalInvite({
v: parsed.data.v,
mesh_id: parsed.data.mesh_id,
mesh_slug: parsed.data.mesh_slug,
broker_url: parsed.data.broker_url,
expires_at: parsed.data.expires_at,
mesh_root_key: parsed.data.mesh_root_key,
role: parsed.data.role,
owner_pubkey: parsed.data.owner_pubkey,
v: payload.v,
mesh_id: payload.mesh_id,
mesh_slug: payload.mesh_slug,
broker_url: payload.broker_url,
expires_at: payload.expires_at,
mesh_root_key: payload.mesh_root_key,
role: payload.role,
owner_pubkey: payload.owner_pubkey,
});
const sigOk = (() => {
try {
return s.crypto_sign_verify_detached(
s.from_hex(parsed.data.signature),
s.from_hex(payload.signature),
s.from_string(canonical),
s.from_hex(parsed.data.owner_pubkey),
s.from_hex(payload.owner_pubkey),
);
} catch {
return false;
@@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
throw new Error("invite signature invalid (link tampered?)");
}
return { payload: parsed.data, raw: link, token: encoded };
return { payload, raw: link, token: encoded };
}
/**
@@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
/**
* Sign and assemble an invite payload → ic://join/... link.
* The canonical bytes (everything except signature) are signed with
* the mesh owner's ed25519 secret key.
*/
export async function buildSignedInvite(args: {
v: 1;

View File

@@ -33,42 +33,89 @@ function text(msg: string, isError = false) {
/**
* Given a `to` string, pick which mesh to send from. Strategies:
* - If `to` looks like a pubkey hex (64 chars), try every client;
* caller is expected to know which mesh the pubkey lives in.
* - If `to` starts with `#`, treat as channel on the first mesh.
* - Otherwise try to match a displayName (TODO — needs list_peers).
* - If `to` looks like a pubkey hex (64 chars), use as-is.
* - If `to` starts with `#`, treat as channel.
* - If `to` is `*`, treat as broadcast.
* - Otherwise resolve as a display name via list_peers.
*
* For now the MVP: if only one mesh is joined, use that. Otherwise
* require the caller to prefix with `<mesh-slug>:`.
* Explicit mesh prefix `<mesh-slug>:<target>` narrows to one mesh.
*/
function resolveClient(to: string): {
async function resolveClient(to: string): Promise<{
client: BrokerClient | null;
targetSpec: string;
error?: string;
} {
}> {
const clients = allClients();
if (clients.length === 0) {
return { client: null, targetSpec: to, error: "no meshes joined" };
}
// Explicit mesh prefix: "mesh-slug:targetspec"
let targetClients = clients;
let target = to;
const colonIdx = to.indexOf(":");
if (colonIdx > 0 && colonIdx < to.length - 1) {
const slug = to.slice(0, colonIdx);
const rest = to.slice(colonIdx + 1);
const match = findClient(slug);
if (match) return { client: match, targetSpec: rest };
if (match) {
targetClients = [match];
target = rest;
}
}
// Single-mesh fast path.
if (clients.length === 1) {
return { client: clients[0]!, targetSpec: to };
// Pubkey, channel, @group, or broadcast — pass through directly.
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target.startsWith("@") || target === "*") {
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
return {
client: null,
targetSpec: target,
error: `multiple meshes joined; prefix target with "<mesh-slug>:" (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
};
}
// Name-based resolution: query each mesh's peer list for a matching displayName.
const nameLower = target.toLowerCase();
for (const c of targetClients) {
const peers = await c.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === nameLower);
if (match) return { client: c, targetSpec: match.pubkey };
// Partial match: if only one peer's name contains the search string.
const partials = peers.filter((p) =>
p.displayName.toLowerCase().includes(nameLower),
);
if (partials.length === 1) {
return { client: c, targetSpec: partials[0]!.pubkey };
}
}
// Single-mesh fallback: let the broker try to resolve it.
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
return {
client: null,
targetSpec: to,
error: `multiple meshes joined; prefix target with "<mesh-slug>:" (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
targetSpec: target,
error: `peer "${target}" not found in any mesh (joined: ${clients.map((c) => c.meshSlug).join(", ")})`,
};
}
// Peer name cache to avoid calling listPeers on every incoming push
const peerNameCache = new Map<string, string>();
let peerNameCacheAge = 0;
const CACHE_TTL_MS = 30_000;
async function resolvePeerName(client: BrokerClient, pubkey: string): Promise<string> {
const now = Date.now();
if (now - peerNameCacheAge > CACHE_TTL_MS) {
peerNameCache.clear();
try {
const peers = await client.listPeers();
for (const p of peers) peerNameCache.set(p.pubkey, p.displayName);
} catch { /* best effort */ }
peerNameCacheAge = now;
}
return peerNameCache.get(pubkey) ?? `peer-${pubkey.slice(0, 8)}`;
}
function decryptFailedWarning(senderPubkey: string): string {
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
@@ -82,32 +129,121 @@ function formatPush(p: InboundPush, meshSlug: string): string {
export async function startMcpServer(): Promise<void> {
const config = loadConfig();
const myName = config.displayName ?? "unnamed";
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push";
const server = new Server(
{ name: "claudemesh", version: "0.1.4" },
{ name: "claudemesh", version: "0.3.0" },
{
capabilities: {
experimental: { "claude/channel": {} },
tools: {},
},
instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions on this machine and elsewhere.
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.
IMPORTANT: When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Do not wait until your current task is finished. Pause what you are doing, reply to the message using send_message, then resume your work. Treat incoming peer messages like a coworker tapping you on the shoulder — answer right away, even if you're in the middle of something.
## 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.
Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with the same target (for direct messages the from_id is the sender's pubkey).
## Tools
| Tool | Description |
|------|-------------|
| send_message(to, message, priority?) | Send to peer name, @group, or * broadcast. \`to\` accepts display name, pubkey hex, @groupname, or *. |
| list_peers(mesh_slug?) | List connected peers with status, summary, groups, and roles. |
| check_messages() | Drain buffered inbound messages (auto-pushed in most cases, use as fallback). |
| set_summary(summary) | Set 1-2 sentence description of your current work, visible to all peers. |
| set_status(status) | Override status: idle, working, or dnd. |
| join_group(name, role?) | Join a @group with optional role (lead, member, observer, or any string). |
| leave_group(name) | Leave a @group. |
| set_state(key, value) | Write shared state; pushes change to all peers. |
| get_state(key) | Read a shared state value. |
| list_state() | List all state keys with values, authors, and timestamps. |
| remember(content, tags?) | Store persistent knowledge with optional tags. |
| recall(query) | Full-text search over mesh memory. |
| forget(id) | Soft-delete a memory entry. |
| share_file(path, name?, tags?) | Share a persistent file with the mesh. |
| get_file(id, save_to) | Download a shared file to a local path. |
| list_files(query?, from?) | Find files shared in the mesh. |
| file_status(id) | Check who has accessed a file. |
| delete_file(id) | Remove a shared file from the mesh. |
| vector_store(collection, text, metadata?) | Store embedding in per-mesh Qdrant collection. |
| vector_search(collection, query, limit?) | Semantic search over stored embeddings. |
| vector_delete(collection, id) | Remove an embedding. |
| list_collections() | List vector collections in this mesh. |
| graph_query(cypher) | Read-only Cypher query on per-mesh Neo4j. |
| graph_execute(cypher) | Write Cypher query (CREATE, MERGE, DELETE). |
| mesh_query(sql) | Run a SELECT query on the per-mesh shared database. |
| mesh_execute(sql) | Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE). |
| mesh_schema() | List tables and columns in the per-mesh shared database. |
| create_stream(name) | Create a real-time data stream in the mesh. |
| publish(stream, data) | Push data to a stream. Subscribers receive it in real-time. |
| subscribe(stream) | Subscribe to a stream. Data pushes arrive as channel notifications. |
| list_streams() | List active streams in the mesh. |
| share_context(summary, files_read?, key_findings?, tags?) | Share session understanding with peers. |
| get_context(query) | Find context from peers who explored an area. |
| list_contexts() | See what all peers currently know. |
| create_task(title, assignee?, priority?, tags?) | Create a work item. |
| 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. |
Available tools:
- list_peers: see joined meshes + their connection status
- send_message: send to a peer pubkey, channel, or broadcast (priority: now/next/low)
- check_messages: drain buffered inbound messages (usually auto-pushed)
- set_summary: 1-2 sentence summary of what you're working on
- set_status: manually override your status (idle/working/dnd)
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
Message priority:
- "now": delivered immediately regardless of recipient status (use sparingly)
- "next" (default): delivered when recipient is idle
- "low": pull-only (check_messages)
Multi-target: send_message accepts an array of targets for the 'to' field.
send_message(to: ["Alice", "@backend"], message: "sprint starts")
Targets are deduplicated — each peer receives the message once.
If you have multiple joined meshes, prefix the \`to\` argument of send_message with \`<mesh-slug>:\` to disambiguate. Otherwise claudemesh picks the single joined mesh.`,
Targeted views: when different audiences need different details about the same event,
send tailored messages instead of one generic broadcast:
send_message(to: "@frontend", message: "Auth v2: useAuth hook changed, see src/auth/")
send_message(to: "@backend", message: "Auth v2: new /api/auth/v2 endpoints, v1 deprecated")
send_message(to: "@pm", message: "Auth v2 done. 3 points, no blockers.")
## Groups
Groups are routing labels. Send to @groupname to multicast to all members. Roles are metadata that peers interpret: a "lead" gathers input before synthesizing a response, a "member" contributes when asked, an "observer" watches silently. Join and leave groups dynamically with join_group/leave_group. Check list_peers to see who belongs to which groups and their roles.
## State
Shared key-value store scoped to the mesh. Use get_state/set_state for live coordination facts (deploy frozen? current sprint? PR queue). set_state pushes the change to all connected peers. Read state before asking peers questions — the answer may already be there. State is operational, not archival.
## Memory
Persistent knowledge that survives across sessions. Use remember(content, tags?) to store lessons, decisions, and incidents. Use recall(query) to search before asking peers. New peers should recall at session start to load institutional knowledge.
## Files
share_file for persistent references, send_message(file:) for ephemeral attachments.
Tags on shared files make them searchable. Use list_files to find what peers shared.
## Vectors
Store and search semantic embeddings. Use vector_store to index content, vector_search to find similar content.
## Graph
Build and query entity relationship graphs. Use graph_execute for writes (CREATE, MERGE), graph_query for reads (MATCH).
## Mesh Database
Per-mesh PostgreSQL database. Use mesh_execute for DDL/DML (CREATE TABLE, INSERT), mesh_query for SELECT, mesh_schema to inspect tables. Schema auto-created on first use.
## Streams
Real-time data channels. create_stream to start one, publish to push data, subscribe to receive pushes. Use for build logs, deploy status, live metrics.
## Context
Share your session understanding with peers. Use share_context after exploring a codebase area. Check get_context before re-reading files another peer already analyzed.
## Tasks
Create and claim work items. create_task to propose work, claim_task to take ownership, complete_task when done. Prevents duplicate effort.
## Priority
- "now": interrupt immediately, even if recipient is in DND (use for urgent: broken deploy, blocking issue)
- "next" (default): deliver when recipient goes idle (normal coordination)
- "low": pull-only via check_messages (FYI, non-blocking context)
## Coordination
Call list_peers at session start to understand who is online, their roles, and what they are working on. If you are a group lead, gather input from members before responding to external requests — do not answer alone. If you are a member, contribute to your lead when asked. Use @group messages for team-wide questions, direct messages for 1:1 coordination. Set a meaningful summary so peers know your current focus.
## Message Mode
Your message mode is "${messageMode}".
- push: messages arrive in real-time as channel notifications. Respond immediately.
- inbox: messages are held. You'll see "[inbox] New message from X" notifications. Call check_messages to read them.
- off: no message notifications. Use check_messages manually to poll.`,
},
);
@@ -129,22 +265,32 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
if (!to || !message)
return text("send_message: `to` and `message` required", true);
const { client, targetSpec, error } = resolveClient(to);
if (!client)
return text(`send_message: ${error ?? "no client resolved"}`, true);
const result = await client.send(
targetSpec,
message,
(priority ?? "next") as Priority,
);
if (!result.ok)
return text(
`send_message failed (${client.meshSlug}): ${result.error}`,
true,
// Handle multi-target: to can be string or string[]
const targets = Array.isArray(to) ? to : [to];
const results: string[] = [];
const seen = new Set<string>(); // dedup by resolved pubkey
for (const target of targets) {
const { client, targetSpec, error } = await resolveClient(target);
if (!client) {
results.push(`${target}: ${error ?? "no client resolved"}`);
continue;
}
if (seen.has(targetSpec)) continue; // dedup
seen.add(targetSpec);
const result = await client.send(
targetSpec,
message,
(priority ?? "next") as Priority,
);
return text(
`Sent to ${targetSpec} via ${client.meshSlug} [${priority ?? "next"}] → ${result.messageId}`,
);
if (!result.ok) {
results.push(`${target}: ${result.error}`);
} else {
results.push(`${target}${result.messageId}`);
}
}
return text(results.join("\n"));
}
case "list_peers": {
@@ -168,7 +314,8 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
} else {
const peerLines = peers.map((p) => {
const summary = p.summary ? ` — "${p.summary}"` : "";
return `- **${p.displayName}** [${p.status}] (${p.pubkey.slice(0, 12)}…)${summary}`;
const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr} (${p.pubkey.slice(0, 12)}…)${summary}`;
});
sections.push(`${header}\n${peerLines.join("\n")}`);
}
@@ -176,6 +323,24 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
return text(sections.join("\n\n"));
}
case "message_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("message_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("message_status: not connected", true);
const result = await client.messageStatus(id);
if (!result) return text(`Message ${id} not found or timed out.`);
const recipientLines = result.recipients.map(
(r: { name: string; pubkey: string; status: string }) =>
` - ${r.name} (${r.pubkey.slice(0, 12)}…): ${r.status}`,
);
return text(
`Message ${id.slice(0, 12)}… → ${result.targetSpec}\n` +
`Delivered: ${result.delivered}${result.deliveredAt ? ` at ${result.deliveredAt}` : ""}\n` +
`Recipients:\n${recipientLines.join("\n")}`,
);
}
case "check_messages": {
const drained: string[] = [];
for (const c of allClients()) {
@@ -205,6 +370,343 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
return text(`Status set to ${s} across ${allClients().length} mesh(es).`);
}
case "join_group": {
const { name: groupName, role } = (args ?? {}) as { name?: string; role?: string };
if (!groupName) return text("join_group: `name` required", true);
for (const c of allClients()) await c.joinGroup(groupName, role);
return text(`Joined @${groupName}${role ? ` as ${role}` : ""}`);
}
case "leave_group": {
const { name: groupName } = (args ?? {}) as { name?: string };
if (!groupName) return text("leave_group: `name` required", true);
for (const c of allClients()) await c.leaveGroup(groupName);
return text(`Left @${groupName}`);
}
// --- State ---
case "set_state": {
const { key, value } = (args ?? {}) as { key?: string; value?: unknown };
if (!key) return text("set_state: `key` required", true);
for (const c of allClients()) await c.setState(key, value);
return text(`State set: ${key} = ${JSON.stringify(value)}`);
}
case "get_state": {
const { key } = (args ?? {}) as { key?: string };
if (!key) return text("get_state: `key` required", true);
const client = allClients()[0];
if (!client) return text("get_state: not connected", true);
const result = await client.getState(key);
if (!result) return text(`State "${key}" not found.`);
return text(`${key} = ${JSON.stringify(result.value)} (set by ${result.updatedBy} at ${result.updatedAt})`);
}
case "list_state": {
const client = allClients()[0];
if (!client) return text("list_state: not connected", true);
const entries = await client.listState();
if (entries.length === 0) return text("No shared state set.");
const lines = entries.map(e => `- **${e.key}** = ${JSON.stringify(e.value)} (by ${e.updatedBy})`);
return text(lines.join("\n"));
}
// --- Memory ---
case "remember": {
const { content, tags } = (args ?? {}) as { content?: string; tags?: string[] };
if (!content) return text("remember: `content` required", true);
const client = allClients()[0];
if (!client) return text("remember: not connected", true);
const id = await client.remember(content, tags);
return text(`Remembered${id ? ` (${id})` : ""}: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}"`);
}
case "recall": {
const { query } = (args ?? {}) as { query?: string };
if (!query) return text("recall: `query` required", true);
const client = allClients()[0];
if (!client) return text("recall: not connected", true);
const memories = await client.recall(query);
if (memories.length === 0) return text(`No memories found for "${query}".`);
const lines = memories.map(m => `- [${m.id.slice(0, 8)}] ${m.content} (by ${m.rememberedBy}, ${m.rememberedAt})`);
return text(`${memories.length} memor${memories.length === 1 ? 'y' : 'ies'}:\n${lines.join("\n")}`);
}
case "forget": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("forget: `id` required", true);
const client = allClients()[0];
if (!client) return text("forget: not connected", true);
await client.forget(id);
return text(`Forgotten: ${id}`);
}
// --- Files ---
case "share_file": {
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: 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})`);
}
case "get_file": {
const { id, save_to } = (args ?? {}) as { id?: string; save_to?: string };
if (!id || !save_to) return text("get_file: `id` and `save_to` required", true);
const client = allClients()[0];
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);
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");
const { dirname } = await import("node:path");
mkdirSync(dirname(save_to), { recursive: true });
writeFileSync(save_to, Buffer.from(await res.arrayBuffer()));
return text(`Downloaded: ${result.name}${save_to}`);
}
case "list_files": {
const { query, from } = (args ?? {}) as { query?: string; from?: string };
const client = allClients()[0];
if (!client) return text("list_files: not connected", true);
const files = await client.listFiles(query, from);
if (files.length === 0) return text("No files found.");
const lines = files.map(f =>
`- **${f.name}** (${f.id.slice(0, 8)}…, ${f.size} bytes) by ${f.uploadedBy}${f.tags.length ? ` [${f.tags.join(", ")}]` : ""}`
);
return text(lines.join("\n"));
}
case "file_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("file_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("file_status: not connected", true);
const accesses = await client.fileStatus(id);
if (accesses.length === 0) return text("No one has accessed this file yet.");
const lines = accesses.map(a => `- ${a.peerName} at ${a.accessedAt}`);
return text(`Accessed by:\n${lines.join("\n")}`);
}
case "delete_file": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("delete_file: `id` required", true);
const client = allClients()[0];
if (!client) return text("delete_file: not connected", true);
await client.deleteFile(id);
return text(`Deleted: ${id}`);
}
// --- Vectors ---
case "vector_store": {
const { collection, text: storeText, metadata } = (args ?? {}) as { collection?: string; text?: string; metadata?: Record<string, unknown> };
if (!collection || !storeText) return text("vector_store: `collection` and `text` required", true);
const client = allClients()[0];
if (!client) return text("vector_store: not connected", true);
const id = await client.vectorStore(collection, storeText, metadata);
return text(`Stored in ${collection}${id ? ` (${id})` : ""}`);
}
case "vector_search": {
const { collection, query, limit } = (args ?? {}) as { collection?: string; query?: string; limit?: number };
if (!collection || !query) return text("vector_search: `collection` and `query` required", true);
const client = allClients()[0];
if (!client) return text("vector_search: not connected", true);
const results = await client.vectorSearch(collection, query, limit);
if (results.length === 0) return text(`No results in ${collection} for "${query}".`);
const lines = results.map(r => `- [${r.id.slice(0, 8)}…] (score: ${r.score.toFixed(3)}) ${r.text.slice(0, 120)}${r.text.length > 120 ? "…" : ""}`);
return text(`${results.length} result(s) in ${collection}:\n${lines.join("\n")}`);
}
case "vector_delete": {
const { collection, id } = (args ?? {}) as { collection?: string; id?: string };
if (!collection || !id) return text("vector_delete: `collection` and `id` required", true);
const client = allClients()[0];
if (!client) return text("vector_delete: not connected", true);
await client.vectorDelete(collection, id);
return text(`Deleted ${id} from ${collection}`);
}
case "list_collections": {
const client = allClients()[0];
if (!client) return text("list_collections: not connected", true);
const collections = await client.listCollections();
if (collections.length === 0) return text("No vector collections.");
return text(`Collections:\n${collections.map(c => `- ${c}`).join("\n")}`);
}
// --- Graph ---
case "graph_query": {
const { cypher } = (args ?? {}) as { cypher?: string };
if (!cypher) return text("graph_query: `cypher` required", true);
const client = allClients()[0];
if (!client) return text("graph_query: not connected", true);
const rows = await client.graphQuery(cypher);
if (rows.length === 0) return text("No results.");
return text(JSON.stringify(rows, null, 2));
}
case "graph_execute": {
const { cypher } = (args ?? {}) as { cypher?: string };
if (!cypher) return text("graph_execute: `cypher` required", true);
const client = allClients()[0];
if (!client) return text("graph_execute: not connected", true);
const rows = await client.graphExecute(cypher);
return text(rows.length > 0 ? JSON.stringify(rows, null, 2) : "Executed successfully.");
}
// --- Context ---
case "share_context": {
const { summary, files_read, key_findings, tags } = (args ?? {}) as { summary?: string; files_read?: string[]; key_findings?: string[]; tags?: string[] };
if (!summary) return text("share_context: `summary` required", true);
const client = allClients()[0];
if (!client) return text("share_context: not connected", true);
await client.shareContext(summary, files_read, key_findings, tags);
return text(`Context shared: "${summary.slice(0, 80)}${summary.length > 80 ? "…" : ""}"`);
}
case "get_context": {
const { query } = (args ?? {}) as { query?: string };
if (!query) return text("get_context: `query` required", true);
const client = allClients()[0];
if (!client) return text("get_context: not connected", true);
const contexts = await client.getContext(query);
if (contexts.length === 0) return text(`No context found for "${query}".`);
const lines = contexts.map(c => {
const files = c.filesRead.length ? `\n Files: ${c.filesRead.join(", ")}` : "";
const findings = c.keyFindings.length ? `\n Findings: ${c.keyFindings.join("; ")}` : "";
return `- **${c.peerName}** (${c.updatedAt}): ${c.summary}${files}${findings}`;
});
return text(`${contexts.length} context(s):\n${lines.join("\n")}`);
}
case "list_contexts": {
const client = allClients()[0];
if (!client) return text("list_contexts: not connected", true);
const contexts = await client.listContexts();
if (contexts.length === 0) return text("No peer contexts shared yet.");
const lines = contexts.map(c => `- **${c.peerName}**: ${c.summary}${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}`);
return text(`Peer contexts:\n${lines.join("\n")}`);
}
// --- Tasks ---
case "create_task": {
const { title, assignee, priority, tags } = (args ?? {}) as { title?: string; assignee?: string; priority?: string; tags?: string[] };
if (!title) return text("create_task: `title` required", true);
const client = allClients()[0];
if (!client) return text("create_task: not connected", true);
const id = await client.createTask(title, assignee, priority, tags);
return text(`Task created${id ? ` (${id})` : ""}: "${title}"${assignee ? `${assignee}` : ""}`);
}
case "claim_task": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("claim_task: `id` required", true);
const client = allClients()[0];
if (!client) return text("claim_task: not connected", true);
await client.claimTask(id);
return text(`Claimed task: ${id}`);
}
case "complete_task": {
const { id, result } = (args ?? {}) as { id?: string; result?: string };
if (!id) return text("complete_task: `id` required", true);
const client = allClients()[0];
if (!client) return text("complete_task: not connected", true);
await client.completeTask(id, result);
return text(`Completed task: ${id}${result ? `${result}` : ""}`);
}
case "list_tasks": {
const { status, assignee } = (args ?? {}) as { status?: string; assignee?: string };
const client = allClients()[0];
if (!client) return text("list_tasks: not connected", true);
const tasks = await client.listTasks(status, assignee);
if (tasks.length === 0) return text("No tasks found.");
const lines = tasks.map(t => `- [${t.id.slice(0, 8)}…] **${t.title}** (${t.status}, ${t.priority}) ${t.assignee ? `${t.assignee}` : "unassigned"} (by ${t.createdBy})`);
return text(`${tasks.length} task(s):\n${lines.join("\n")}`);
}
// --- Mesh Database ---
case "mesh_query": {
const { sql: querySql } = (args ?? {}) as { sql?: string };
if (!querySql) return text("mesh_query: `sql` required", true);
const client = allClients()[0];
if (!client) return text("mesh_query: not connected", true);
const result = await client.meshQuery(querySql);
if (!result) return text("mesh_query: query failed or timed out", true);
if (result.rows.length === 0) return text(`Query returned 0 rows.`);
const header = `| ${result.columns.join(" | ")} |`;
const sep = `| ${result.columns.map(() => "---").join(" | ")} |`;
const rows = result.rows.map(r => `| ${result.columns.map(c => String(r[c] ?? "")).join(" | ")} |`);
return text(`${result.rowCount} row(s):\n${header}\n${sep}\n${rows.join("\n")}`);
}
case "mesh_execute": {
const { sql: execSql } = (args ?? {}) as { sql?: string };
if (!execSql) return text("mesh_execute: `sql` required", true);
const client = allClients()[0];
if (!client) return text("mesh_execute: not connected", true);
await client.meshExecute(execSql);
return text(`Executed.`);
}
case "mesh_schema": {
const client = allClients()[0];
if (!client) return text("mesh_schema: not connected", true);
const tables = await client.meshSchema();
if (!tables || tables.length === 0) return text("No tables in mesh database.");
const lines = tables.map(t => `**${t.name}**: ${t.columns.map(c => `${c.name} (${c.type}${c.nullable ? ", nullable" : ""})`).join(", ")}`);
return text(lines.join("\n"));
}
// --- Streams ---
case "create_stream": {
const { name: streamName } = (args ?? {}) as { name?: string };
if (!streamName) return text("create_stream: `name` required", true);
const client = allClients()[0];
if (!client) return text("create_stream: not connected", true);
const streamId = await client.createStream(streamName);
return text(`Stream created: ${streamName}${streamId ? ` (${streamId})` : ""}`);
}
case "publish": {
const { stream: pubStream, data: pubData } = (args ?? {}) as { stream?: string; data?: unknown };
if (!pubStream) return text("publish: `stream` required", true);
const client = allClients()[0];
if (!client) return text("publish: not connected", true);
await client.publish(pubStream, pubData);
return text(`Published to ${pubStream}.`);
}
case "subscribe": {
const { stream: subStream } = (args ?? {}) as { stream?: string };
if (!subStream) return text("subscribe: `stream` required", true);
const client = allClients()[0];
if (!client) return text("subscribe: not connected", true);
await client.subscribe(subStream);
return text(`Subscribed to ${subStream}. Data pushes will arrive as channel notifications.`);
}
case "list_streams": {
const client = allClients()[0];
if (!client) return text("list_streams: not connected", true);
const streams = await client.listStreams();
if (streams.length === 0) return text("No active streams.");
const lines = streams.map(s => `- **${s.name}** (${s.id.slice(0, 8)}…) by ${s.createdBy}, ${s.subscriberCount} subscriber(s)`);
return text(lines.join("\n"));
}
case "mesh_info": {
const client = allClients()[0];
if (!client) return text("mesh_info: not connected", true);
const info = await client.meshInfo();
if (!info) return text("mesh_info: timed out", true);
const lines = [
`**Mesh**: ${info.mesh}`,
`**Peers**: ${info.peers}`,
`**Groups**: ${(info.groups as string[])?.join(", ") || "none"}`,
`**State keys**: ${(info.stateKeys as string[])?.join(", ") || "none"}`,
`**Memories**: ${info.memoryCount}`,
`**Files**: ${info.fileCount}`,
`**Tasks**: open=${(info.tasks as any)?.open ?? 0}, claimed=${(info.tasks as any)?.claimed ?? 0}, done=${(info.tasks as any)?.done ?? 0}`,
`**Streams**: ${(info.streams as string[])?.join(", ") || "none"}`,
`**Tables**: ${(info.tables as string[])?.join(", ") || "none"}`,
`**Your name**: ${info.yourName}`,
`**Your groups**: ${(info.yourGroups as any[])?.map((g: any) => `@${g.name}${g.role ? ':' + g.role : ''}`).join(", ") || "none"}`,
];
return text(lines.join("\n"));
}
default:
return text(`Unknown tool: ${name}`, true);
}
@@ -220,11 +722,33 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
// 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) => {
if (messageMode === "off") return;
const fromPubkey = msg.senderPubkey || "";
const fromName = fromPubkey
? `peer-${fromPubkey.slice(0, 8)}`
? await resolvePeerName(client, fromPubkey)
: "unknown";
if (messageMode === "inbox") {
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[inbox] New message from ${fromName}. Use check_messages to read.`,
meta: { kind: "inbox_notification", from_name: fromName },
},
});
} catch { /* best effort */ }
return;
}
// push mode — full content
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
try {
await server.notification({
@@ -243,10 +767,43 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
},
},
});
} 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`);
}
});
client.onStreamData(async (evt) => {
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[stream:${evt.stream}] from ${evt.publishedBy}: ${JSON.stringify(evt.data)}`,
meta: {
kind: "stream_data",
stream: evt.stream,
published_by: evt.publishedBy,
},
},
});
} catch { /* best effort */ }
});
client.onStateChange(async (change) => {
try {
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[state] ${change.key} = ${JSON.stringify(change.value)} (set by ${change.updatedBy})`,
meta: {
kind: "state_change",
key: change.key,
updated_by: change.updatedBy,
},
},
});
} catch { /* best effort */ }
});
}
const shutdown = (): void => {

View File

@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
{
name: "send_message",
description:
"Send a message to a peer in one of your joined meshes. `to` is a peer display name, hex pubkey, or `#channel`. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
inputSchema: {
type: "object",
properties: {
to: {
type: "string",
description: "Peer name, pubkey, or #channel",
oneOf: [
{ type: "string", description: "Peer name, pubkey, @group" },
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
],
description: "Single target or array of targets",
},
message: { type: "string", description: "Message text" },
priority: {
@@ -44,6 +47,21 @@ export const TOOLS: Tool[] = [
},
},
},
{
name: "message_status",
description:
"Check the delivery status of a sent message. Shows whether each recipient received it.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Message ID (returned by send_message)",
},
},
required: ["id"],
},
},
{
name: "check_messages",
description:
@@ -78,4 +96,463 @@ export const TOOLS: Tool[] = [
required: ["status"],
},
},
{
name: "join_group",
description:
"Join a group with an optional role. Other peers see your group membership in list_peers.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Group name (without @)" },
role: {
type: "string",
description: "Your role in the group (e.g. lead, member, observer)",
},
},
required: ["name"],
},
},
{
name: "leave_group",
description: "Leave a group.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Group name (without @)" },
},
required: ["name"],
},
},
// --- State tools ---
{
name: "set_state",
description:
"Set a shared state value visible to all peers in the mesh. Pushes a change notification.",
inputSchema: {
type: "object",
properties: {
key: { type: "string" },
value: { description: "Any JSON value" },
},
required: ["key", "value"],
},
},
{
name: "get_state",
description: "Read a shared state value.",
inputSchema: {
type: "object",
properties: {
key: { type: "string" },
},
required: ["key"],
},
},
{
name: "list_state",
description: "List all shared state keys and values in the mesh.",
inputSchema: { type: "object", properties: {} },
},
// --- Memory tools ---
{
name: "remember",
description:
"Store persistent knowledge in the mesh's shared memory. Survives across sessions.",
inputSchema: {
type: "object",
properties: {
content: {
type: "string",
description: "The knowledge to remember",
},
tags: {
type: "array",
items: { type: "string" },
description: "Optional categorization tags",
},
},
required: ["content"],
},
},
{
name: "recall",
description: "Search the mesh's shared memory by relevance.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
},
{
name: "forget",
description: "Remove a memory from the mesh's shared knowledge.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Memory ID to forget" },
},
required: ["id"],
},
},
// --- File tools ---
{
name: "share_file",
description:
"Share a persistent file with the mesh. All current and future peers can access it.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Local file path to share" },
name: {
type: "string",
description: "Display name (defaults to filename)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["path"],
},
},
{
name: "get_file",
description: "Download a shared file to a local path.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
save_to: {
type: "string",
description: "Local path to save the file",
},
},
required: ["id", "save_to"],
},
},
{
name: "list_files",
description: "List files shared in the mesh.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search by name or tags" },
from: { type: "string", description: "Filter by uploader name" },
},
},
},
{
name: "file_status",
description: "Check who has accessed a shared file.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
{
name: "delete_file",
description: "Remove a shared file from the mesh.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
// --- Vector tools ---
{
name: "vector_store",
description:
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
inputSchema: {
type: "object",
properties: {
collection: { type: "string", description: "Collection name" },
text: { type: "string", description: "Text to embed and store" },
metadata: {
type: "object",
description: "Optional metadata to attach",
},
},
required: ["collection", "text"],
},
},
{
name: "vector_search",
description: "Semantic search over stored embeddings in a collection.",
inputSchema: {
type: "object",
properties: {
collection: { type: "string", description: "Collection name" },
query: { type: "string", description: "Search query text" },
limit: {
type: "number",
description: "Max results (default: 10)",
},
},
required: ["collection", "query"],
},
},
{
name: "vector_delete",
description: "Remove an embedding from a collection.",
inputSchema: {
type: "object",
properties: {
collection: { type: "string", description: "Collection name" },
id: { type: "string", description: "Embedding ID to delete" },
},
required: ["collection", "id"],
},
},
{
name: "list_collections",
description: "List vector collections in this mesh.",
inputSchema: { type: "object", properties: {} },
},
// --- Graph tools ---
{
name: "graph_query",
description:
"Run a read-only Cypher query on the per-mesh Neo4j database.",
inputSchema: {
type: "object",
properties: {
cypher: { type: "string", description: "Cypher MATCH query" },
},
required: ["cypher"],
},
},
{
name: "graph_execute",
description:
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
inputSchema: {
type: "object",
properties: {
cypher: { type: "string", description: "Cypher write query" },
},
required: ["cypher"],
},
},
// --- Mesh Database tools ---
{
name: "mesh_query",
description:
"Run a SELECT query on the per-mesh shared database.",
inputSchema: {
type: "object",
properties: {
sql: { type: "string", description: "SQL SELECT query" },
},
required: ["sql"],
},
},
{
name: "mesh_execute",
description:
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
inputSchema: {
type: "object",
properties: {
sql: { type: "string", description: "SQL statement" },
},
required: ["sql"],
},
},
{
name: "mesh_schema",
description:
"List tables and columns in the per-mesh shared database.",
inputSchema: { type: "object", properties: {} },
},
// --- Stream tools ---
{
name: "create_stream",
description:
"Create a real-time data stream in the mesh.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Stream name" },
},
required: ["name"],
},
},
{
name: "publish",
description:
"Push data to a stream. Subscribers receive it in real-time.",
inputSchema: {
type: "object",
properties: {
stream: { type: "string", description: "Stream name" },
data: { description: "Any JSON data to publish" },
},
required: ["stream", "data"],
},
},
{
name: "subscribe",
description:
"Subscribe to a stream. Data pushes arrive as channel notifications.",
inputSchema: {
type: "object",
properties: {
stream: { type: "string", description: "Stream name" },
},
required: ["stream"],
},
},
{
name: "list_streams",
description:
"List active streams in the mesh.",
inputSchema: { type: "object", properties: {} },
},
// --- Context tools ---
{
name: "share_context",
description:
"Share your session understanding with the mesh. Call after exploring a codebase area.",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Summary of what you explored/learned",
},
files_read: {
type: "array",
items: { type: "string" },
description: "File paths you read",
},
key_findings: {
type: "array",
items: { type: "string" },
description: "Key findings or insights",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["summary"],
},
},
{
name: "get_context",
description:
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (file path, topic, etc.)",
},
},
required: ["query"],
},
},
{
name: "list_contexts",
description: "See what all peers currently know about the codebase.",
inputSchema: { type: "object", properties: {} },
},
// --- Task tools ---
{
name: "create_task",
description: "Create a work item for the mesh.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Task title" },
assignee: {
type: "string",
description: "Peer name to assign (optional)",
},
priority: {
type: "string",
enum: ["low", "normal", "high", "urgent"],
description: "Priority level (default: normal)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["title"],
},
},
{
name: "claim_task",
description: "Claim an unclaimed task to take ownership.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Task ID" },
},
required: ["id"],
},
},
{
name: "complete_task",
description: "Mark a task as done with an optional result summary.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Task ID" },
result: {
type: "string",
description: "Summary of what was done",
},
},
required: ["id"],
},
},
{
name: "list_tasks",
description: "List tasks filtered by status and/or assignee.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["open", "claimed", "completed"],
description: "Filter by status",
},
assignee: {
type: "string",
description: "Filter by assignee name",
},
},
},
},
// --- Mesh info ---
{
name: "mesh_info",
description:
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
inputSchema: { type: "object", properties: {} },
},
];

View File

@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs {
to: string; // peer name, pubkey, or #channel
to: string | string[]; // peer name, pubkey, @group, or array of targets
message: string;
priority?: Priority;
}

View File

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

View File

@@ -21,6 +21,7 @@ import {
isDirectTarget,
} from "../crypto/envelope";
import { signHello } from "../crypto/hello-sig";
import { generateKeypair } from "../crypto/keypair";
export type Priority = "now" | "next" | "low";
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
@@ -30,6 +31,7 @@ export interface PeerInfo {
displayName: string;
status: string;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
}
@@ -74,6 +76,13 @@ export class BrokerClient {
private pushHandlers = new Set<PushHandler>();
private pushBuffer: InboundPush[] = [];
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
private stateResolvers: Array<(result: { key: string; value: unknown; updatedBy: string; updatedAt: string } | null) => void> = [];
private stateListResolvers: Array<(entries: Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) => void> = [];
private memoryStoreResolvers: Array<(id: string | null) => void> = [];
private memoryRecallResolvers: Array<(memories: Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) => void> = [];
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private closed = false;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
@@ -83,6 +92,7 @@ export class BrokerClient {
private mesh: JoinedMesh,
private opts: {
onStatusChange?: (status: ConnStatus) => void;
displayName?: string;
debug?: boolean;
} = {},
) {}
@@ -109,8 +119,15 @@ export class BrokerClient {
return new Promise<void>((resolve, reject) => {
const onOpen = async (): Promise<void> => {
this.debug("ws open → signing + sending hello");
this.debug("ws open → generating session keypair + signing hello");
try {
// Only generate session keypair on first connect, not reconnects
if (!this.sessionPubkey) {
const sessionKP = await generateKeypair();
this.sessionPubkey = sessionKP.publicKey;
this.sessionSecretKey = sessionKP.secretKey;
}
const { timestamp, signature } = await signHello(
this.mesh.meshId,
this.mesh.memberId,
@@ -123,6 +140,8 @@ export class BrokerClient {
meshId: this.mesh.meshId,
memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey,
sessionPubkey: this.sessionPubkey,
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || this.opts.displayName || undefined,
sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
@@ -202,7 +221,7 @@ export class BrokerClient {
const env = await encryptDirect(
message,
targetSpec,
this.mesh.secretKey,
this.sessionSecretKey ?? this.mesh.secretKey,
);
nonce = env.nonce;
ciphertext = env.ciphertext;
@@ -299,6 +318,474 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
}
/** Join a group with an optional role. */
async joinGroup(name: string, role?: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "join_group", name, role }));
}
/** Leave a group. */
async leaveGroup(name: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "leave_group", name }));
}
// --- State ---
/** Set a shared state value visible to all peers in the mesh. */
async setState(key: string, value: unknown): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "set_state", key, value }));
}
/** Read a shared state value. */
async getState(key: string): Promise<{ key: string; value: unknown; updatedBy: string; updatedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.stateResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_state", key }));
setTimeout(() => {
const idx = this.stateResolvers.indexOf(resolve);
if (idx !== -1) {
this.stateResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
});
}
/** List all shared state keys and values. */
async listState(): Promise<Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.stateListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_state" }));
setTimeout(() => {
const idx = this.stateListResolvers.indexOf(resolve);
if (idx !== -1) {
this.stateListResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
// --- Memory ---
/** Store persistent knowledge in the mesh's shared memory. */
async remember(content: string, tags?: string[]): Promise<string | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.memoryStoreResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "remember", content, tags }));
setTimeout(() => {
const idx = this.memoryStoreResolvers.indexOf(resolve);
if (idx !== -1) {
this.memoryStoreResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
});
}
/** Search the mesh's shared memory by relevance. */
async recall(query: string): Promise<Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.memoryRecallResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "recall", query }));
setTimeout(() => {
const idx = this.memoryRecallResolvers.indexOf(resolve);
if (idx !== -1) {
this.memoryRecallResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Remove a memory from the mesh's shared knowledge. */
async forget(memoryId: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "forget", memoryId }));
}
/** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
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 streamDataHandlers = new Set<(data: { stream: string; data: unknown; publishedBy: string }) => void>();
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.messageStatusResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "message_status", messageId }));
setTimeout(() => {
const idx = this.messageStatusResolvers.indexOf(resolve);
if (idx !== -1) { this.messageStatusResolvers.splice(idx, 1); resolve(null); }
}, 5_000);
});
}
// --- Files ---
/** Get a download URL for a shared file. */
async getFile(fileId: string): Promise<{ url: string; name: 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);
});
}
/** List files shared in the mesh. */
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);
});
}
/** Check who has accessed a shared file. */
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);
});
}
/** Delete a shared file from the mesh. */
async deleteFile(fileId: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
}
/** Upload a file to the broker via HTTP POST. Returns file ID or null. */
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
}): Promise<string | null> {
const { readFileSync } = await import("node:fs");
const { basename } = await import("node:path");
const data = readFileSync(filePath);
const fileName = opts.name ?? basename(filePath);
// Convert WS broker URL to HTTP
const brokerHttp = this.mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
const res = await fetch(`${brokerHttp}/upload`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"X-Mesh-Id": meshId,
"X-Member-Id": memberId,
"X-File-Name": fileName,
"X-Tags": JSON.stringify(opts.tags ?? []),
"X-Persistent": String(opts.persistent ?? true),
"X-Target-Spec": opts.targetSpec ?? "",
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = await res.json() as { ok?: boolean; fileId?: string };
return body.fileId ?? null;
}
// --- Vectors ---
/** Store an embedding in a per-mesh Qdrant collection. */
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);
});
}
/** Semantic search over stored embeddings. */
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);
});
}
/** Remove an embedding from a collection. */
async vectorDelete(collection: string, id: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "vector_delete", collection, id }));
}
/** List vector collections in this mesh. */
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);
});
}
// --- Graph ---
/** Run a read query on the per-mesh Neo4j database. */
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);
});
}
/** Run a write query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database. */
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);
});
}
// --- Context ---
/** Share session understanding with the mesh. */
async shareContext(summary: string, filesRead?: string[], keyFindings?: string[], tags?: string[]): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "share_context", summary, filesRead, keyFindings, tags }));
}
/** Find context from peers who explored an area. */
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);
});
}
/** See what all peers currently know. */
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);
});
}
// --- Tasks ---
/** Create a work item. */
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);
});
}
/** 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 }));
}
/** 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 }));
}
/** 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);
});
}
// --- Mesh Database ---
/** Run a SELECT query on the per-mesh shared database. */
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);
});
}
/** Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE). */
async meshExecute(sql: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "mesh_execute", sql }));
}
/** List tables and columns in the per-mesh shared database. */
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);
});
}
// --- Streams ---
/** Create a real-time data stream in the mesh. */
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);
});
}
/** Push data to a stream. Subscribers receive it in real-time. */
async publish(stream: string, data: unknown): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "publish", stream, data }));
}
/** Subscribe to a stream. Data pushes arrive via onStreamData handler. */
async subscribe(stream: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "subscribe", stream }));
}
/** Unsubscribe from a stream. */
async unsubscribe(stream: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "unsubscribe", stream }));
}
/** List active streams in the mesh. */
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);
});
}
/** Subscribe to stream data pushes. Returns an unsubscribe function. */
onStreamData(handler: (data: { stream: string; data: unknown; publishedBy: string }) => void): () => void {
this.streamDataHandlers.add(handler);
return () => this.streamDataHandlers.delete(handler);
}
/** Subscribe to state change notifications. Returns an unsubscribe function. */
onStateChange(handler: (change: { key: string; value: unknown; updatedBy: string }) => void): () => void {
this.stateChangeHandlers.add(handler);
return () => this.stateChangeHandlers.delete(handler);
}
// --- Mesh info ---
private meshInfoResolvers: Array<(result: Record<string, unknown> | null) => void> = [];
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);
});
}
close(): void {
this.closed = true;
if (this.helloTimer) clearTimeout(this.helloTimer);
@@ -348,7 +835,7 @@ export class BrokerClient {
plaintext = await decryptDirect(
{ nonce, ciphertext },
senderPubkey,
this.mesh.secretKey,
this.sessionSecretKey ?? this.mesh.secretKey,
);
}
// Legacy/broadcast path: no senderPubkey means the message
@@ -365,6 +852,19 @@ export class BrokerClient {
plaintext = null;
}
}
// Fallback: if direct decrypt failed, try plaintext base64 decode.
// This handles broadcasts and key mismatches gracefully.
if (plaintext === null && ciphertext) {
try {
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
// Sanity check: valid UTF-8 text (not binary garbage)
if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) {
plaintext = decoded;
}
} catch {
plaintext = null;
}
}
const push: InboundPush = {
messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""),
@@ -389,6 +889,172 @@ export class BrokerClient {
})();
return;
}
if (msg.type === "state_result") {
const resolver = this.stateResolvers.shift();
if (resolver) {
if (msg.key) {
resolver({
key: String(msg.key),
value: msg.value,
updatedBy: String(msg.updatedBy ?? ""),
updatedAt: String(msg.updatedAt ?? ""),
});
} else {
resolver(null);
}
}
return;
}
if (msg.type === "state_list") {
const entries = (msg.entries as Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) ?? [];
const resolver = this.stateListResolvers.shift();
if (resolver) resolver(entries);
return;
}
if (msg.type === "state_change") {
const change = {
key: String(msg.key ?? ""),
value: msg.value,
updatedBy: String(msg.updatedBy ?? ""),
};
for (const h of this.stateChangeHandlers) {
try { h(change); } catch { /* handler errors are not the transport's problem */ }
}
return;
}
if (msg.type === "memory_stored") {
const resolver = this.memoryStoreResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
return;
}
if (msg.type === "memory_results") {
const memories = (msg.memories as Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) ?? [];
const resolver = this.memoryRecallResolvers.shift();
if (resolver) resolver(memories);
return;
}
if (msg.type === "message_status_result") {
const resolver = this.messageStatusResolvers.shift();
if (resolver) resolver(msg as any);
return;
}
if (msg.type === "file_url") {
const resolver = this.fileUrlResolvers.shift();
if (resolver) {
if (msg.url) {
resolver({ url: String(msg.url), name: String(msg.name ?? "") });
} else {
resolver(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);
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);
return;
}
if (msg.type === "vector_stored") {
const resolver = this.vectorStoredResolvers.shift();
if (resolver) resolver(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);
return;
}
if (msg.type === "collection_list") {
const collections = (msg.collections as string[]) ?? [];
const resolver = this.collectionListResolvers.shift();
if (resolver) resolver(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);
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);
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);
return;
}
if (msg.type === "task_created") {
const resolver = this.taskCreatedResolvers.shift();
if (resolver) resolver(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);
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);
}
}
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);
return;
}
if (msg.type === "stream_created") {
const resolver = this.streamCreatedResolvers.shift();
if (resolver) resolver(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);
return;
}
if (msg.type === "stream_data") {
const evt = {
stream: String(msg.stream ?? ""),
data: msg.data,
publishedBy: String(msg.publishedBy ?? ""),
};
for (const h of this.streamDataHandlers) {
try { h(evt); } catch { /* handler errors are not the transport's problem */ }
}
return;
}
if (msg.type === "mesh_info_result") {
const resolver = this.meshInfoResolvers.shift();
if (resolver) resolver(msg as Record<string, unknown>);
return;
}
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null;

View File

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

View File

@@ -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

@@ -1,5 +1,8 @@
import type { NextConfig } from "next";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { withPayload } = require("@payloadcms/next/withPayload");
import env from "./env.config";
const INTERNAL_PACKAGES = [
@@ -87,6 +90,15 @@ const config: NextConfig = {
"@payloadcms/richtext-lexical",
"sharp",
],
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
},
images: {
remotePatterns: [
{
@@ -121,4 +133,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: env.ANALYZE,
});
export default withBundleAnalyzer(config);
export default withPayload(withBundleAnalyzer(config));

View File

@@ -18,7 +18,7 @@
"@anaralabs/lector": "3.7.3",
"@formatjs/intl-localematcher": "0.6.2",
"@hookform/resolvers": "5.2.2",
"@next/bundle-analyzer": "16.0.10",
"@next/bundle-analyzer": "16.2.2",
"@number-flow/react": "0.5.10",
"@payloadcms/db-postgres": "3.81.0",
"@payloadcms/db-sqlite": "^3.81.0",
@@ -44,7 +44,7 @@
"marked": "16.4.1",
"motion": "12.23.24",
"negotiator": "1.0.0",
"next": "16.0.10",
"next": "16.2.2",
"next-i18n-router": "5.5.5",
"next-themes": "0.4.6",
"nuqs": "2.7.2",

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ export const CallToAction = () => {
</span>
</Link>
<Link
href="#docs"
href="https://github.com/alezmad/claudemesh-cli#readme"
className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-6 py-3.5 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>

View File

@@ -5,11 +5,11 @@ import { Reveal } from "./_reveal";
const ITEMS = [
{
q: "Is claudemesh free?",
a: "Yes — the broker, CLI, dashboard, and SDK are MIT-licensed and free forever. Solo developers and small teams can self-host at no cost. Paid tiers add hosted brokers, SSO, audit retention, and support.",
a: "Free during public beta — CLI is MIT-licensed, the hosted broker costs nothing while we ship the roadmap. Paid tiers launch when the dashboard ships. Beta users keep the free plan for life.",
},
{
q: "How do I get started?",
a: "Install the broker with one curl command. Add one env var to your Claude Code config. Your session joins the mesh. `npx claudemesh init` does both in 60 seconds.",
a: "One command: `curl -fsSL claudemesh.com/install | bash`. The script checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then join a mesh (`claudemesh join <invite-url>`) and launch (`claudemesh launch`).",
},
{
q: "Does claudemesh send my code or prompts to the cloud?",
@@ -29,7 +29,7 @@ const ITEMS = [
},
{
q: "Which Claude Code versions work with claudemesh?",
a: "Claude Code 2.0 and above. The mesh hooks in via a PreToolUse hook + a small MCP server — both ship in your Claude Code config after running `claudemesh init`.",
a: "Claude Code 2.0 and above. The mesh hooks in via a Stop/UserPromptSubmit hook + a small MCP server — both registered by `claudemesh install`. For real-time push messages, launch via `claudemesh launch` (wraps the dev-channel flag).",
},
{
q: "How is this different from MCP?",

View File

@@ -4,27 +4,46 @@ import { Reveal, SectionIcon } from "./_reveal";
const FEATURES = [
{
key: "onboard",
tab: "Onboarding",
title: "Bootstrap any teammate",
body: "New hire's Claude inherits the team's context library on day one. No hand-holding, no week-long repo tour.",
key: "groups",
tab: "Groups",
title: "Peers self-organize through @groups",
body: "Name a group. Assign roles. Route messages to @frontend, @reviewers, or @all. The lead gathers; members contribute. No hardcoded pipelines — conventions in system prompts.",
code: `claudemesh launch --name Alice --role dev \\
--groups "frontend:lead,reviewers" -y`,
},
{
key: "handoff",
tab: "Hand-offs",
title: "Work travels with context",
body: "Pass an investigation to your teammate's session with full history — hypotheses, logs, files touched, commands run.",
key: "state",
tab: "Shared state",
title: "Live facts the whole mesh can read",
body: "Set a value, every peer sees the change immediately. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
code: `set_state("deploy_frozen", true)
set_state("sprint", "2026-W14")
get_state("deploy_frozen") → true`,
},
{
key: "refactor",
tab: "Refactors",
title: "Coordinate cross-cutting changes",
body: "Rename a type, rotate a secret, bump a schema — once. Every other agent picks up the change from its own repo.",
key: "memory",
tab: "Memory",
title: "The mesh gets smarter over time",
body: "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.",
code: `remember("Payments API rate-limits at 100 req/s
after March incident", tags: ["payments"])
recall("rate limit") → ranked results`,
},
{
key: "coordinate",
tab: "Coordination",
title: "Five patterns, zero orchestrator",
body: "Lead-gather: one lead collects from the group. Chain review: work passes through each member. Delegation: lead assigns subtasks. Voting: members set state, lead tallies. Flood: everyone responds. All through system prompts — no broker code.",
code: `send_message(to: "@frontend",
message: "auth API changed, update hooks")
send_message(to: "@pm",
message: "auth v2 done, 3 points, no blockers")`,
},
];
export const Features = () => {
const [active, setActive] = useState(0);
const feature = FEATURES[active]!;
return (
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32">
<div className="mx-auto max-w-[var(--cm-max-w)]">
@@ -36,40 +55,19 @@ export const Features = () => {
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
What could your mesh do?
What your mesh can do today
</h2>
</Reveal>
<Reveal delay={2} className="mt-10 flex justify-center">
<div
className="flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3 text-[13px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-clay)]">$</span>
<span>curl -fsSL claudemesh.sh/install | bash</span>
<button
className="ml-2 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[10px] text-[var(--cm-fg-tertiary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
aria-label="Copy"
>
copy
</button>
</div>
</Reveal>
<Reveal delay={3}>
<Reveal delay={2}>
<p
className="mt-4 text-center text-sm text-[var(--cm-fg-tertiary)]"
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Free forever for solo developers · Or read the{" "}
<a
href="#"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
>
documentation
</a>
30+ MCP tools. Groups, state, memory, messaging all shipped.
</p>
</Reveal>
<Reveal delay={4}>
<div className="mt-16 flex justify-center gap-2">
<Reveal delay={3}>
<div className="mt-12 flex flex-wrap justify-center gap-2">
{FEATURES.map((f, i) => (
<button
key={f.key}
@@ -86,19 +84,29 @@ export const Features = () => {
</button>
))}
</div>
<div className="mx-auto mt-10 max-w-3xl rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-10 text-center">
<h3
className="mb-4 text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{FEATURES[active]?.title}
</h3>
<p
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{FEATURES[active]?.body}
</p>
<div className="mx-auto mt-8 max-w-3xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]">
<div className="p-8 pb-4">
<h3
className="mb-3 text-[24px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{feature.title}
</h3>
<p
className="text-[14px] leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{feature.body}
</p>
</div>
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-gray-900)] px-8 py-5">
<pre
className="text-[12px] leading-[1.7] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<code>{feature.code}</code>
</pre>
</div>
</div>
</Reveal>
</div>

View File

@@ -2,12 +2,12 @@ import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal";
const LOGOS = [
"Vercel",
"Linear",
"Stripe",
"Supabase",
"Shopify",
"Figma",
"Claude Code",
"MCP",
"libsodium",
"Bun",
"TypeScript",
"MIT",
];
export const Hero = () => {
@@ -55,11 +55,12 @@ export const Hero = () => {
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Peer mesh for Claude reachable from anywhere you are. Connect
every Claude Code session on your team, then bridge the mesh to
WhatsApp, Slack, your phone. Terminal is one client, not THE client.
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
routes ciphertext; it never reads your messages.
<span className="block pt-2 text-[var(--cm-clay)]">
Free and open-source. Forever.
Open-source CLI. Free during public beta.
</span>
</p>
</Reveal>
@@ -81,7 +82,7 @@ export const Hero = () => {
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="text-[var(--cm-clay)]">$</span>
<span>curl -fsSL claudemesh.sh/install | bash</span>
<span>curl -fsSL claudemesh.com/install | bash</span>
</div>
</div>
</Reveal>
@@ -93,7 +94,7 @@ export const Hero = () => {
>
Or{" "}
<Link
href="#docs"
href="https://github.com/alezmad/claudemesh-cli#readme"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
>
read the documentation

View File

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

View File

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

View File

@@ -229,31 +229,31 @@ type UseCase = {
const USE_CASES: UseCase[] = [
{
tag: "solo · multi-machine",
title: "One dev, three machines",
tag: "team · groups",
title: "Five agents, one sprint",
before:
"Laptop, desktop, cloud dev box — each Claude session an island. You re-explain what you're doing every time you switch machines.",
now: "Your desktop's Claude asks your laptop's Claude what it was touching. Context travels with you. The machine stops mattering.",
"Each Claude works alone. When the frontend agent finishes auth, nobody tells the backend agent. You relay by hand. The PM asks for a status update; you copy-paste from three terminals.",
now: "Launch five sessions with --name and --groups. The @frontend lead finishes auth and messages @backend directly. The PM's Claude reads shared state: sprint number, PR queue, deploy status. Nobody relays anything.",
limits:
"Both peers have to be online. It shares live conversational context — not git state, not open files.",
"Peers must be online to receive direct messages. Group messages queue until delivery. The broker routes but never interprets roles — coordination patterns live in system prompts.",
},
{
tag: "team · cross-repo",
title: "Bug Alice fixed, Bob rediscovers",
tag: "knowledge · memory",
title: "New hire's Claude knows the codebase",
before:
"Alice in payments-api fixes a Stripe signature bug. Two weeks later, Bob in checkout-frontend hits the same thing. Alice's fix is buried in a PR thread. Bob re-solves it for three hours.",
now: "Bob's Claude asks the mesh: who's seen this? Alice's Claude self-nominates with context. Bob solves in ten minutes. Alice isn't interrupted — her Claude surfaces the history on its own.",
"Alice in payments-api fixes a Stripe rate-limit bug. Three weeks later, a new hire hits the same wall. The fix is buried in a PR thread. They re-solve it for hours.",
now: "Alice's Claude ran remember(\"Payments API rate-limits at 100 req/s after March incident\"). The new hire's Claude runs recall(\"rate limit\") and gets ranked results. Ten minutes, not three hours.",
limits:
"Each Claude stays inside its own repo. Nobody's reading anyone else's files. Information flows at the agent layer, with a human still on the PR.",
"Memory stores text, not code diffs. Each Claude stays inside its own repo. Knowledge flows at the agent layer — the human still reviews the PR.",
},
{
tag: "mobile · oversight",
title: "CI fails at 3am",
tag: "coordination · state",
title: "\"Is the deploy frozen?\" answered in zero messages",
before:
"Alert on your phone. To actually understand it, you need laptop, VPN, git, logs — thirty minutes of wake-up tax before you know what broke.",
now: "WhatsApp gateway peer forwards the alert. You ask the ops-server Claude what triggered it. It answers. You say roll it back. Done from bed.",
"You ask in Slack. Someone answers twenty minutes later. Meanwhile two PRs merge. The deploy breaks. Nobody knew it was frozen.",
now: "set_state(\"deploy_frozen\", true). Every peer sees the change instantly. get_state(\"deploy_frozen\") returns true. No conversation needed. Shared operational facts, not shared opinions.",
limits:
"The WhatsApp/phone gateway is on the v0.2 roadmap — the protocol is ready, the bot isn't shipped yet. Someone could build it in a weekend.",
"State is operational — it lives as long as the mesh. Use memory for permanent knowledge. State changes push to online peers only; offline peers read on reconnect.",
},
];

View File

@@ -28,6 +28,61 @@ services:
networks:
- claudemesh-internal
minio:
image: minio/minio
command: server /data --console-address ":9001"
restart: always
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: claudemesh
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-changeme}
expose:
- "9000"
networks:
- claudemesh-internal
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 5s
start_period: 10s
retries: 3
qdrant:
image: qdrant/qdrant
restart: always
volumes:
- qdrant-data:/qdrant/storage
expose:
- "6333"
networks:
- claudemesh-internal
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:6333/readyz"]
interval: 15s
timeout: 5s
retries: 3
neo4j:
image: neo4j:5
restart: always
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-changeme}
NEO4J_PLUGINS: '[]'
volumes:
- neo4j-data:/data
expose:
- "7687"
- "7474"
networks:
- claudemesh-internal
healthcheck:
test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "${NEO4J_PASSWORD:-changeme}", "RETURN 1"]
interval: 15s
timeout: 5s
start_period: 30s
retries: 3
broker:
image: ${BROKER_IMAGE:-claudemesh-broker:latest}
restart: always
@@ -40,11 +95,26 @@ services:
MAX_CONNECTIONS_PER_MESH: ${MAX_CONNECTIONS_PER_MESH:-100}
MAX_MESSAGE_BYTES: ${MAX_MESSAGE_BYTES:-65536}
HOOK_RATE_LIMIT_PER_MIN: ${HOOK_RATE_LIMIT_PER_MIN:-30}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: claudemesh
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-changeme}
MINIO_USE_SSL: "false"
QDRANT_URL: http://qdrant:6333
NEO4J_URL: bolt://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
expose:
- "7900"
networks:
- coolify
- claudemesh-internal
depends_on:
minio:
condition: service_healthy
qdrant:
condition: service_healthy
neo4j:
condition: service_healthy
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 15s
@@ -85,6 +155,11 @@ services:
start_period: 20s
retries: 3
volumes:
minio-data:
qdrant-data:
neo4j-data:
networks:
# Coolify's shared Traefik network — must already exist on the host
coolify:

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."presence" ADD COLUMN "display_name" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."presence" ADD COLUMN "session_pubkey" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."message_queue" ADD COLUMN "sender_session_pubkey" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."presence" ADD COLUMN "groups" jsonb DEFAULT '[]'::jsonb;

View File

@@ -0,0 +1,27 @@
CREATE TABLE "mesh"."memory" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"content" text NOT NULL,
"tags" text[] DEFAULT '{}',
"remembered_by" text,
"remembered_by_name" text,
"remembered_at" timestamp DEFAULT now() NOT NULL,
"forgotten_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "mesh"."state" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"key" text NOT NULL,
"value" jsonb NOT NULL,
"updated_by_presence" text,
"updated_by_name" text,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mesh"."memory" ADD CONSTRAINT "memory_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "mesh"."memory" ADD CONSTRAINT "memory_remembered_by_member_id_fk" FOREIGN KEY ("remembered_by") REFERENCES "mesh"."member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mesh"."state" ADD CONSTRAINT "state_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "state_mesh_key_idx" ON "mesh"."state" USING btree ("mesh_id","key");--> statement-breakpoint
ALTER TABLE "mesh"."memory" ADD COLUMN IF NOT EXISTS "search_vector" tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "memory_search_idx" ON "mesh"."memory" USING gin("search_vector");

View File

@@ -0,0 +1,28 @@
CREATE TABLE "mesh"."file" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"name" text NOT NULL,
"size_bytes" integer NOT NULL,
"mime_type" text,
"minio_key" text NOT NULL,
"tags" text[] DEFAULT '{}',
"persistent" boolean DEFAULT true NOT NULL,
"uploaded_by_name" text,
"uploaded_by_member" text,
"target_spec" text,
"uploaded_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "mesh"."file_access" (
"id" text PRIMARY KEY NOT NULL,
"file_id" text NOT NULL,
"peer_session_pubkey" text,
"peer_name" text,
"accessed_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD CONSTRAINT "file_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD CONSTRAINT "file_uploaded_by_member_member_id_fk" FOREIGN KEY ("uploaded_by_member") REFERENCES "mesh"."member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mesh"."file_access" ADD CONSTRAINT "file_access_file_id_file_id_fk" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,33 @@
CREATE TABLE "mesh"."context" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"presence_id" text,
"peer_name" text,
"summary" text NOT NULL,
"files_read" text[] DEFAULT '{}',
"key_findings" text[] DEFAULT '{}',
"tags" text[] DEFAULT '{}',
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "mesh"."task" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"title" text NOT NULL,
"assignee" text,
"claimed_by_name" text,
"claimed_by_presence" text,
"priority" text DEFAULT 'normal' NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"tags" text[] DEFAULT '{}',
"result" text,
"created_by_name" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"claimed_at" timestamp,
"completed_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_presence_id_presence_id_fk" FOREIGN KEY ("presence_id") REFERENCES "mesh"."presence"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mesh"."task" ADD CONSTRAINT "task_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "mesh"."task" ADD CONSTRAINT "task_claimed_by_presence_presence_id_fk" FOREIGN KEY ("claimed_by_presence") REFERENCES "mesh"."presence"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,10 @@
CREATE TABLE "mesh"."stream" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"name" text NOT NULL,
"created_by_name" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mesh"."stream" ADD CONSTRAINT "stream_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "stream_mesh_name_idx" ON "mesh"."stream" USING btree ("mesh_id","name");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,62 @@
"when": 1775463897329,
"tag": "0003_add-presence-summary",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1775468683383,
"tag": "0004_add-presence-display-name",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1775470435032,
"tag": "0005_add-presence-session-pubkey",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1775470979207,
"tag": "0006_add-sender-session-pubkey",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1775476994511,
"tag": "0007_add-presence-groups",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1775477883426,
"tag": "0008_add-state-and-memory",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1775480008546,
"tag": "0009_add-file-tables",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1775480729014,
"tag": "0010_add-context-and-tasks",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1775481222701,
"tag": "0011_add-streams",
"breakpoints": true
}
]
}

View File

@@ -1,10 +1,12 @@
import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgSchema,
timestamp,
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
@@ -192,12 +194,15 @@ export const presence = meshSchema.table("presence", {
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
sessionId: text().notNull(),
sessionPubkey: text(),
displayName: text(),
pid: integer().notNull(),
cwd: text().notNull(),
status: presenceStatusEnum().notNull().default("idle"),
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
statusUpdatedAt: timestamp().defaultNow().notNull(),
summary: text(),
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
connectedAt: timestamp().defaultNow().notNull(),
lastPingAt: timestamp().defaultNow().notNull(),
disconnectedAt: timestamp(),
@@ -220,6 +225,7 @@ export const messageQueue = meshSchema.table("message_queue", {
senderMemberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
senderSessionPubkey: text(),
targetSpec: text().notNull(),
priority: messagePriorityEnum().notNull().default("next"),
nonce: text().notNull(),
@@ -247,6 +253,142 @@ export const pendingStatus = meshSchema.table("pending_status", {
appliedAt: timestamp(),
});
/**
* Shared key-value state scoped to a mesh. Any peer can read/write.
* Changes push to all connected peers in real time.
*/
export const meshState = meshSchema.table(
"state",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
key: text().notNull(),
value: jsonb().notNull(),
updatedByPresence: text(),
updatedByName: text(),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [uniqueIndex("state_mesh_key_idx").on(table.meshId, table.key)],
);
/**
* Persistent shared memory for a mesh. Full-text searchable via a
* tsvector generated column + GIN index added in raw SQL migration.
*/
export const meshMemory = meshSchema.table("memory", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
content: text().notNull(),
tags: text().array().default([]),
rememberedBy: text().references(() => meshMember.id),
rememberedByName: text(),
rememberedAt: timestamp().defaultNow().notNull(),
forgottenAt: timestamp(),
});
/**
* File metadata for shared files in a mesh. Actual bytes live in MinIO;
* this table tracks ownership, access control, and soft-deletion.
*/
export const meshFile = meshSchema.table("file", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
name: text().notNull(),
sizeBytes: integer().notNull(),
mimeType: text(),
minioKey: text().notNull(),
tags: text().array().default([]),
persistent: boolean().notNull().default(true),
uploadedByName: text(),
uploadedByMember: text().references(() => meshMember.id),
targetSpec: text(), // null = entire mesh
uploadedAt: timestamp().defaultNow().notNull(),
expiresAt: timestamp(),
deletedAt: timestamp(),
});
/**
* Access log for file downloads. Tracks which peer accessed which file
* and when, for auditability and read-receipt semantics.
*/
export const meshFileAccess = meshSchema.table("file_access", {
id: text().primaryKey().notNull().$defaultFn(generateId),
fileId: text()
.references(() => meshFile.id, { onDelete: "cascade" })
.notNull(),
peerSessionPubkey: text(),
peerName: text(),
accessedAt: timestamp().defaultNow().notNull(),
});
/**
* Per-peer context snapshot. Each peer (presence) 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.
*/
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(),
});
/**
* Mesh-scoped task board. Peers can create tasks, claim them, and mark
* them done. Lightweight project management for multi-agent workflows.
*/
export const meshTask = meshSchema.table("task", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
title: text().notNull(),
assignee: text(),
claimedByName: text(),
claimedByPresence: text().references(() => presence.id),
priority: text().notNull().default("normal"),
status: text().notNull().default("open"),
tags: text().array().default([]),
result: text(),
createdByName: text(),
createdAt: timestamp().defaultNow().notNull(),
claimedAt: timestamp(),
completedAt: timestamp(),
});
/**
* Named real-time data channels within a mesh. One peer publishes, all
* subscribers receive. No message history — streams are live.
* Use cases: build logs, deploy status, monitoring data, live code diffs.
*/
export const meshStream = meshSchema.table(
"stream",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
name: text().notNull(),
createdByName: text(),
createdAt: timestamp().defaultNow().notNull(),
},
(table) => [uniqueIndex("stream_mesh_name_idx").on(table.meshId, table.name)],
);
export const meshRelations = relations(mesh, ({ one, many }) => ({
owner: one(user, {
fields: [mesh.ownerUserId],
@@ -307,6 +449,43 @@ export const auditLogRelations = relations(auditLog, ({ one }) => ({
}),
}));
export const meshStateRelations = relations(meshState, ({ one }) => ({
mesh: one(mesh, {
fields: [meshState.meshId],
references: [mesh.id],
}),
}));
export const meshMemoryRelations = relations(meshMemory, ({ one }) => ({
mesh: one(mesh, {
fields: [meshMemory.meshId],
references: [mesh.id],
}),
member: one(meshMember, {
fields: [meshMemory.rememberedBy],
references: [meshMember.id],
}),
}));
export const meshFileRelations = relations(meshFile, ({ one, many }) => ({
mesh: one(mesh, {
fields: [meshFile.meshId],
references: [mesh.id],
}),
uploader: one(meshMember, {
fields: [meshFile.uploadedByMember],
references: [meshMember.id],
}),
accesses: many(meshFileAccess),
}));
export const meshFileAccessRelations = relations(meshFileAccess, ({ one }) => ({
file: one(meshFile, {
fields: [meshFileAccess.fileId],
references: [meshFile.id],
}),
}));
export const selectMeshSchema = createSelectSchema(mesh);
export const insertMeshSchema = createInsertSchema(mesh);
export const selectMemberSchema = createSelectSchema(meshMember);
@@ -336,3 +515,61 @@ export type SelectMessageQueue = typeof messageQueue.$inferSelect;
export type InsertMessageQueue = typeof messageQueue.$inferInsert;
export type SelectPendingStatus = typeof pendingStatus.$inferSelect;
export type InsertPendingStatus = typeof pendingStatus.$inferInsert;
export const selectMeshStateSchema = createSelectSchema(meshState);
export const insertMeshStateSchema = createInsertSchema(meshState);
export const selectMeshMemorySchema = createSelectSchema(meshMemory);
export const insertMeshMemorySchema = createInsertSchema(meshMemory);
export type SelectMeshState = typeof meshState.$inferSelect;
export type InsertMeshState = typeof meshState.$inferInsert;
export type SelectMeshMemory = typeof meshMemory.$inferSelect;
export type InsertMeshMemory = typeof meshMemory.$inferInsert;
export const selectMeshFileSchema = createSelectSchema(meshFile);
export const insertMeshFileSchema = createInsertSchema(meshFile);
export const selectMeshFileAccessSchema = createSelectSchema(meshFileAccess);
export const insertMeshFileAccessSchema = createInsertSchema(meshFileAccess);
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 selectMeshContextSchema = createSelectSchema(meshContext);
export const insertMeshContextSchema = createInsertSchema(meshContext);
export const selectMeshTaskSchema = createSelectSchema(meshTask);
export const insertMeshTaskSchema = createInsertSchema(meshTask);
export type SelectMeshContext = typeof meshContext.$inferSelect;
export type InsertMeshContext = typeof meshContext.$inferInsert;
export type SelectMeshTask = typeof meshTask.$inferSelect;
export type InsertMeshTask = typeof meshTask.$inferInsert;
export const meshContextRelations = relations(meshContext, ({ one }) => ({
mesh: one(mesh, {
fields: [meshContext.meshId],
references: [mesh.id],
}),
presence: one(presence, {
fields: [meshContext.presenceId],
references: [presence.id],
}),
}));
export const meshTaskRelations = relations(meshTask, ({ one }) => ({
mesh: one(mesh, {
fields: [meshTask.meshId],
references: [mesh.id],
}),
claimedPresence: one(presence, {
fields: [meshTask.claimedByPresence],
references: [presence.id],
}),
}));
export const meshStreamRelations = relations(meshStream, ({ one }) => ({
mesh: one(mesh, {
fields: [meshStream.meshId],
references: [mesh.id],
}),
}));
export const selectMeshStreamSchema = createSelectSchema(meshStream);
export const insertMeshStreamSchema = createInsertSchema(meshStream);
export type SelectMeshStream = typeof meshStream.$inferSelect;
export type InsertMeshStream = typeof meshStream.$inferInsert;

655
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff