Compare commits
48 Commits
v0.1.16
...
5cb4cc4fe7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb4cc4fe7 | ||
|
|
eeac47c360 | ||
|
|
0bb9d71a26 | ||
|
|
3ff7a61e3f | ||
|
|
e76ade64d2 | ||
|
|
59848f0d3e | ||
|
|
d0fa1c028f | ||
|
|
8f925d9a9e | ||
|
|
4ce1034dcd | ||
|
|
e26a36e543 | ||
|
|
60c74d9463 | ||
|
|
6fba9bd4eb | ||
|
|
5bcc1fe323 | ||
|
|
e70f0ed1ff | ||
|
|
5f696f47ea | ||
|
|
ccb9fb2a68 | ||
|
|
898c061089 | ||
|
|
f7a6559429 | ||
|
|
579d0c3d3e | ||
|
|
190f5a958e | ||
|
|
03661e1b68 | ||
|
|
d451fc296e | ||
|
|
3da5d71275 | ||
|
|
cdf335f609 | ||
|
|
0cd16ff358 | ||
|
|
3e9707276d | ||
|
|
82cfee315c | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 | ||
|
|
a70c5fd124 | ||
|
|
5c62d287cf | ||
|
|
9ae378c2e3 | ||
|
|
7381738f0b | ||
|
|
8c6b0c0e07 | ||
|
|
ec9626503c | ||
|
|
820ec085b2 | ||
|
|
9e6f6d7bc9 | ||
|
|
7194e7d28e | ||
|
|
0b4e389f2b | ||
|
|
7a5f786e0c | ||
|
|
10e5fdcfd1 | ||
|
|
cc6e56aef9 | ||
|
|
1aaa483d60 | ||
|
|
99d9d19079 | ||
|
|
888078876a | ||
|
|
02b1e5695f |
@@ -15,10 +15,13 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@turbostarter/prettier-config",
|
"prettier": "@turbostarter/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@qdrant/js-client-rest": "1.17.0",
|
||||||
"@turbostarter/db": "workspace:*",
|
"@turbostarter/db": "workspace:*",
|
||||||
"@turbostarter/shared": "workspace:*",
|
"@turbostarter/shared": "workspace:*",
|
||||||
"drizzle-orm": "0.44.7",
|
"drizzle-orm": "0.44.7",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
|
"minio": "8.0.7",
|
||||||
|
"neo4j-driver": "6.0.1",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,14 @@ const envSchema = z.object({
|
|||||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
||||||
|
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||||
|
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||||
|
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||||
|
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||||
|
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||||
|
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||||
|
NEO4J_USER: z.string().default("neo4j"),
|
||||||
|
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
28
apps/broker/src/minio.ts
Normal file
28
apps/broker/src/minio.ts
Normal 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, "-")}`;
|
||||||
|
}
|
||||||
22
apps/broker/src/neo4j-client.ts
Normal file
22
apps/broker/src/neo4j-client.ts
Normal 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
24
apps/broker/src/qdrant.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ export interface WSHelloMessage {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
cwd: string;
|
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. */
|
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
/** ed25519 signature (hex) over the canonical hello bytes:
|
/** ed25519 signature (hex) over the canonical hello bytes:
|
||||||
@@ -84,6 +86,8 @@ export interface WSPushMessage {
|
|||||||
nonce: string;
|
nonce: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** Optional semantic tag — "reminder" when delivered by the scheduler. */
|
||||||
|
subtype?: "reminder";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client → broker: manual status override (dnd, forced idle). */
|
/** Client → broker: manual status override (dnd, forced idle). */
|
||||||
@@ -103,12 +107,63 @@ export interface WSSetSummaryMessage {
|
|||||||
summary: string;
|
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. */
|
/** Broker → client: acknowledgement for a send. */
|
||||||
export interface WSAckMessage {
|
export interface WSAckMessage {
|
||||||
type: "ack";
|
type: "ack";
|
||||||
id: string; // echoes client-side correlation id
|
id: string; // echoes client-side correlation id
|
||||||
messageId: string;
|
messageId: string;
|
||||||
queued: boolean;
|
queued: boolean;
|
||||||
|
_reqId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Broker → client: hello handshake acknowledgement. */
|
/** Broker → client: hello handshake acknowledgement. */
|
||||||
@@ -126,9 +181,480 @@ export interface WSPeersListMessage {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
status: PeerStatus;
|
status: PeerStatus;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
connectedAt: string;
|
connectedAt: string;
|
||||||
}>;
|
}>;
|
||||||
|
_reqId?: 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;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: response to list_state. */
|
||||||
|
export interface WSStateListMessage {
|
||||||
|
type: "state_list";
|
||||||
|
entries: Array<{
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
updatedBy: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: acknowledgement for a remember. */
|
||||||
|
export interface WSMemoryStoredMessage {
|
||||||
|
type: "memory_stored";
|
||||||
|
id: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: response to recall. */
|
||||||
|
export interface WSMemoryResultsMessage {
|
||||||
|
type: "memory_results";
|
||||||
|
memories: Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
rememberedBy: string;
|
||||||
|
rememberedAt: string;
|
||||||
|
}>;
|
||||||
|
_reqId?: 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: confirmation that a vector point was stored. */
|
||||||
|
export interface WSVectorStoredMessage {
|
||||||
|
type: "vector_stored";
|
||||||
|
id: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: vector search results. */
|
||||||
|
export interface WSVectorResultsMessage {
|
||||||
|
type: "vector_results";
|
||||||
|
results: Array<{
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: list of vector collections. */
|
||||||
|
export interface WSCollectionListMessage {
|
||||||
|
type: "collection_list";
|
||||||
|
collections: string[];
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: graph query results. */
|
||||||
|
export interface WSGraphResultMessage {
|
||||||
|
type: "graph_result";
|
||||||
|
records: Array<Record<string, unknown>>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: mesh SQL query results. */
|
||||||
|
export interface WSMeshQueryResultMessage {
|
||||||
|
type: "mesh_query_result";
|
||||||
|
columns: string[];
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
rowCount: number;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }>;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }>;
|
||||||
|
_reqId?: 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";
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client → broker: grant a peer access to an encrypted file. */
|
||||||
|
export interface WSGrantFileAccessMessage {
|
||||||
|
type: "grant_file_access";
|
||||||
|
fileId: string;
|
||||||
|
peerPubkey: string;
|
||||||
|
sealedKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: presigned URL for downloading a file. */
|
||||||
|
export interface WSFileUrlMessage {
|
||||||
|
type: "file_url";
|
||||||
|
fileId: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
encrypted?: boolean;
|
||||||
|
sealedKey?: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: list of files in the mesh. */
|
||||||
|
export interface WSFileListMessage {
|
||||||
|
type: "file_list";
|
||||||
|
files: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
tags: string[];
|
||||||
|
uploadedBy: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
persistent: boolean;
|
||||||
|
encrypted: boolean;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: acknowledgement for grant_file_access. */
|
||||||
|
export interface WSGrantFileAccessOkMessage {
|
||||||
|
type: "grant_file_access_ok";
|
||||||
|
fileId: string;
|
||||||
|
peerPubkey: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: access log for a file. */
|
||||||
|
export interface WSFileStatusResultMessage {
|
||||||
|
type: "file_status_result";
|
||||||
|
fileId: string;
|
||||||
|
accesses: Array<{
|
||||||
|
peerName: string;
|
||||||
|
accessedAt: string;
|
||||||
|
}>;
|
||||||
|
_reqId?: 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;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: response to list_contexts. */
|
||||||
|
export interface WSContextListMessage {
|
||||||
|
type: "context_list";
|
||||||
|
contexts: Array<{
|
||||||
|
peerName: string;
|
||||||
|
summary: string;
|
||||||
|
tags: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
_reqId?: 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;
|
||||||
|
_reqId?: 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;
|
||||||
|
}>;
|
||||||
|
_reqId?: 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;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: real-time data pushed from a stream. */
|
||||||
|
export interface WSStreamDataMessage {
|
||||||
|
type: "stream_data";
|
||||||
|
stream: string;
|
||||||
|
data: unknown;
|
||||||
|
publishedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: confirmation that a stream subscription was registered. */
|
||||||
|
export interface WSSubscribedMessage {
|
||||||
|
type: "subscribed";
|
||||||
|
stream: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: response to list_streams. */
|
||||||
|
export interface WSStreamListMessage {
|
||||||
|
type: "stream_list";
|
||||||
|
streams: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
subscriberCount: number;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Broker → client: structured error. */
|
/** Broker → client: structured error. */
|
||||||
@@ -137,6 +663,63 @@ export interface WSErrorMessage {
|
|||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scheduled messages ---
|
||||||
|
|
||||||
|
/** Client → broker: schedule a message for future delivery. */
|
||||||
|
export interface WSScheduleMessage {
|
||||||
|
type: "schedule";
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
/** Unix timestamp (ms) when to deliver. */
|
||||||
|
deliverAt: number;
|
||||||
|
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
|
||||||
|
subtype?: "reminder";
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client → broker: list pending scheduled messages for this member. */
|
||||||
|
export interface WSListScheduledMessage {
|
||||||
|
type: "list_scheduled";
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client → broker: cancel a scheduled message by id. */
|
||||||
|
export interface WSCancelScheduledMessage {
|
||||||
|
type: "cancel_scheduled";
|
||||||
|
scheduledId: string;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: acknowledgement for schedule, carries the assigned id. */
|
||||||
|
export interface WSScheduledAckMessage {
|
||||||
|
type: "scheduled_ack";
|
||||||
|
scheduledId: string;
|
||||||
|
deliverAt: number;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: list of pending scheduled messages. */
|
||||||
|
export interface WSScheduledListMessage {
|
||||||
|
type: "scheduled_list";
|
||||||
|
messages: Array<{
|
||||||
|
id: string;
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
deliverAt: number;
|
||||||
|
createdAt: number;
|
||||||
|
}>;
|
||||||
|
_reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broker → client: cancel confirmation. */
|
||||||
|
export interface WSCancelScheduledAckMessage {
|
||||||
|
type: "cancel_scheduled_ack";
|
||||||
|
scheduledId: string;
|
||||||
|
ok: boolean;
|
||||||
|
_reqId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WSClientMessage =
|
export type WSClientMessage =
|
||||||
@@ -144,11 +727,79 @@ export type WSClientMessage =
|
|||||||
| WSSendMessage
|
| WSSendMessage
|
||||||
| WSSetStatusMessage
|
| WSSetStatusMessage
|
||||||
| WSListPeersMessage
|
| WSListPeersMessage
|
||||||
| WSSetSummaryMessage;
|
| WSSetSummaryMessage
|
||||||
|
| WSJoinGroupMessage
|
||||||
|
| WSLeaveGroupMessage
|
||||||
|
| WSSetStateMessage
|
||||||
|
| WSGetStateMessage
|
||||||
|
| WSListStateMessage
|
||||||
|
| WSRememberMessage
|
||||||
|
| WSRecallMessage
|
||||||
|
| WSForgetMessage
|
||||||
|
| WSMessageStatusMessage
|
||||||
|
| WSGetFileMessage
|
||||||
|
| WSListFilesMessage
|
||||||
|
| WSFileStatusMessage
|
||||||
|
| WSDeleteFileMessage
|
||||||
|
| WSGrantFileAccessMessage
|
||||||
|
| WSShareContextMessage
|
||||||
|
| WSGetContextMessage
|
||||||
|
| WSListContextsMessage
|
||||||
|
| WSCreateTaskMessage
|
||||||
|
| WSClaimTaskMessage
|
||||||
|
| WSCompleteTaskMessage
|
||||||
|
| WSListTasksMessage
|
||||||
|
| WSVectorStoreMessage
|
||||||
|
| WSVectorSearchMessage
|
||||||
|
| WSVectorDeleteMessage
|
||||||
|
| WSListCollectionsMessage
|
||||||
|
| WSGraphQueryMessage
|
||||||
|
| WSGraphExecuteMessage
|
||||||
|
| WSMeshQueryMessage
|
||||||
|
| WSMeshExecuteMessage
|
||||||
|
| WSMeshSchemaMessage
|
||||||
|
| WSCreateStreamMessage
|
||||||
|
| WSPublishMessage
|
||||||
|
| WSSubscribeMessage
|
||||||
|
| WSUnsubscribeMessage
|
||||||
|
| WSListStreamsMessage
|
||||||
|
| WSMeshInfoMessage
|
||||||
|
| WSScheduleMessage
|
||||||
|
| WSListScheduledMessage
|
||||||
|
| WSCancelScheduledMessage;
|
||||||
|
|
||||||
export type WSServerMessage =
|
export type WSServerMessage =
|
||||||
| WSHelloAckMessage
|
| WSHelloAckMessage
|
||||||
| WSPushMessage
|
| WSPushMessage
|
||||||
| WSAckMessage
|
| WSAckMessage
|
||||||
| WSPeersListMessage
|
| WSPeersListMessage
|
||||||
|
| WSStateChangeMessage
|
||||||
|
| WSStateResultMessage
|
||||||
|
| WSStateListMessage
|
||||||
|
| WSMemoryStoredMessage
|
||||||
|
| WSMemoryResultsMessage
|
||||||
|
| WSMessageStatusResultMessage
|
||||||
|
| WSFileUrlMessage
|
||||||
|
| WSFileListMessage
|
||||||
|
| WSFileStatusResultMessage
|
||||||
|
| WSGrantFileAccessOkMessage
|
||||||
|
| WSContextSharedMessage
|
||||||
|
| WSContextResultsMessage
|
||||||
|
| WSContextListMessage
|
||||||
|
| WSTaskCreatedMessage
|
||||||
|
| WSTaskListMessage
|
||||||
|
| WSVectorStoredMessage
|
||||||
|
| WSVectorResultsMessage
|
||||||
|
| WSCollectionListMessage
|
||||||
|
| WSGraphResultMessage
|
||||||
|
| WSMeshQueryResultMessage
|
||||||
|
| WSMeshSchemaResultMessage
|
||||||
|
| WSStreamCreatedMessage
|
||||||
|
| WSStreamDataMessage
|
||||||
|
| WSSubscribedMessage
|
||||||
|
| WSStreamListMessage
|
||||||
|
| WSMeshInfoResultMessage
|
||||||
|
| WSScheduledAckMessage
|
||||||
|
| WSScheduledListMessage
|
||||||
|
| WSCancelScheduledAckMessage
|
||||||
| WSErrorMessage;
|
| WSErrorMessage;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.1.16",
|
"version": "0.6.8",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
"citty": "0.2.2",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "4.1.13"
|
"zod": "4.1.13"
|
||||||
|
|||||||
59
apps/cli/src/commands/connect.ts
Normal file
59
apps/cli/src/commands/connect.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
|
||||||
|
*
|
||||||
|
* Opens a connection to one mesh, runs a callback, then closes cleanly.
|
||||||
|
* The caller never deals with connect/close lifecycle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import { BrokerClient } from "../ws/client";
|
||||||
|
import { loadConfig } from "../state/config";
|
||||||
|
import type { JoinedMesh } from "../state/config";
|
||||||
|
|
||||||
|
export interface ConnectOpts {
|
||||||
|
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
|
||||||
|
meshSlug?: string | null;
|
||||||
|
/** Display name for this session. Defaults to hostname-pid. */
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withMesh<T>(
|
||||||
|
opts: ConnectOpts,
|
||||||
|
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const config = loadConfig();
|
||||||
|
if (config.meshes.length === 0) {
|
||||||
|
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mesh: JoinedMesh;
|
||||||
|
if (opts.meshSlug) {
|
||||||
|
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
|
||||||
|
if (!found) {
|
||||||
|
console.error(
|
||||||
|
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
mesh = found;
|
||||||
|
} else if (config.meshes.length === 1) {
|
||||||
|
mesh = config.meshes[0]!;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
|
||||||
|
const client = new BrokerClient(mesh, { displayName });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await fn(client, mesh);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/cli/src/commands/inbox.ts
Normal file
60
apps/cli/src/commands/inbox.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh inbox` — read pending peer messages.
|
||||||
|
*
|
||||||
|
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||||
|
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
import type { InboundPush } from "../ws/client";
|
||||||
|
|
||||||
|
export interface InboxFlags {
|
||||||
|
mesh?: string;
|
||||||
|
json?: boolean;
|
||||||
|
wait?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(msg: InboundPush, useColor: boolean): string {
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
|
||||||
|
const from = msg.senderPubkey.slice(0, 8);
|
||||||
|
const time = new Date(msg.createdAt).toLocaleTimeString();
|
||||||
|
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
|
||||||
|
|
||||||
|
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInbox(flags: InboxFlags): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const waitMs = (flags.wait ?? 1) * 1000;
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
|
// Wait briefly for broker to push any held messages.
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||||
|
|
||||||
|
const messages = client.drainPushBuffer();
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(messages, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
console.log(dim(`No messages on mesh "${mesh.slug}".`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
|
||||||
|
console.log("");
|
||||||
|
for (const msg of messages) {
|
||||||
|
console.log(formatMessage(msg, useColor));
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
58
apps/cli/src/commands/info.ts
Normal file
58
apps/cli/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||||
|
*
|
||||||
|
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
import { loadConfig } from "../state/config";
|
||||||
|
|
||||||
|
export interface InfoFlags {
|
||||||
|
mesh?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
|
const [brokerInfo, peers, state] = await Promise.all([
|
||||||
|
client.meshInfo(),
|
||||||
|
client.listPeers(),
|
||||||
|
client.listState(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
slug: mesh.slug,
|
||||||
|
meshId: mesh.meshId,
|
||||||
|
memberId: mesh.memberId,
|
||||||
|
brokerUrl: mesh.brokerUrl,
|
||||||
|
displayName: config.displayName ?? null,
|
||||||
|
peerCount: peers.length,
|
||||||
|
stateCount: state.length,
|
||||||
|
...(brokerInfo ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
|
||||||
|
console.log(dim(` mesh: ${mesh.meshId}`));
|
||||||
|
console.log(dim(` member: ${mesh.memberId}`));
|
||||||
|
console.log(` peers: ${peers.length} connected`);
|
||||||
|
console.log(` state: ${state.length} keys`);
|
||||||
|
if (brokerInfo && typeof brokerInfo === "object") {
|
||||||
|
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||||
|
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
||||||
|
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -212,6 +212,88 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||||
|
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||||
|
*/
|
||||||
|
const CLAUDEMESH_TOOLS = [
|
||||||
|
"mcp__claudemesh__send_message",
|
||||||
|
"mcp__claudemesh__list_peers",
|
||||||
|
"mcp__claudemesh__check_messages",
|
||||||
|
"mcp__claudemesh__set_summary",
|
||||||
|
"mcp__claudemesh__set_status",
|
||||||
|
"mcp__claudemesh__join_group",
|
||||||
|
"mcp__claudemesh__leave_group",
|
||||||
|
"mcp__claudemesh__get_state",
|
||||||
|
"mcp__claudemesh__set_state",
|
||||||
|
"mcp__claudemesh__list_state",
|
||||||
|
"mcp__claudemesh__remember",
|
||||||
|
"mcp__claudemesh__recall",
|
||||||
|
"mcp__claudemesh__forget",
|
||||||
|
"mcp__claudemesh__share_file",
|
||||||
|
"mcp__claudemesh__get_file",
|
||||||
|
"mcp__claudemesh__list_files",
|
||||||
|
"mcp__claudemesh__file_status",
|
||||||
|
"mcp__claudemesh__delete_file",
|
||||||
|
"mcp__claudemesh__vector_store",
|
||||||
|
"mcp__claudemesh__vector_search",
|
||||||
|
"mcp__claudemesh__vector_delete",
|
||||||
|
"mcp__claudemesh__list_collections",
|
||||||
|
"mcp__claudemesh__graph_query",
|
||||||
|
"mcp__claudemesh__graph_execute",
|
||||||
|
"mcp__claudemesh__mesh_info",
|
||||||
|
"mcp__claudemesh__ping_mesh",
|
||||||
|
"mcp__claudemesh__message_status",
|
||||||
|
"mcp__claudemesh__share_context",
|
||||||
|
"mcp__claudemesh__get_context",
|
||||||
|
"mcp__claudemesh__list_contexts",
|
||||||
|
"mcp__claudemesh__create_task",
|
||||||
|
"mcp__claudemesh__claim_task",
|
||||||
|
"mcp__claudemesh__complete_task",
|
||||||
|
"mcp__claudemesh__list_tasks",
|
||||||
|
"mcp__claudemesh__create_stream",
|
||||||
|
"mcp__claudemesh__publish",
|
||||||
|
"mcp__claudemesh__subscribe",
|
||||||
|
"mcp__claudemesh__list_streams",
|
||||||
|
"mcp__claudemesh__mesh_execute",
|
||||||
|
"mcp__claudemesh__mesh_query",
|
||||||
|
"mcp__claudemesh__mesh_schema",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||||
|
* Merges into any existing list — never overwrites other entries.
|
||||||
|
* Returns which tools were added vs already present.
|
||||||
|
*/
|
||||||
|
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||||
|
const settings = readClaudeSettings();
|
||||||
|
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||||
|
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||||
|
writeClaudeSettings(settings);
|
||||||
|
}
|
||||||
|
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove claudemesh tools from allowedTools.
|
||||||
|
* Leaves all other entries intact. Returns count removed.
|
||||||
|
*/
|
||||||
|
function uninstallAllowedTools(): number {
|
||||||
|
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||||
|
const settings = readClaudeSettings();
|
||||||
|
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||||
|
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||||
|
const kept = existing.filter((t) => !toolSet.has(t));
|
||||||
|
const removed = existing.length - kept.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
settings.allowedTools = kept;
|
||||||
|
writeClaudeSettings(settings);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||||
* idempotent on the command string. Returns counts for reporting.
|
* idempotent on the command string. Returns counts for reporting.
|
||||||
@@ -321,6 +403,26 @@ export function runInstall(args: string[] = []): void {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||||
|
// --dangerously-skip-permissions just to call mesh tools.
|
||||||
|
try {
|
||||||
|
const { added, unchanged } = installAllowedTools();
|
||||||
|
if (added.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||||
|
);
|
||||||
|
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||||
|
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||||
|
} else {
|
||||||
|
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||||
|
}
|
||||||
|
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||||
if (!skipHooks) {
|
if (!skipHooks) {
|
||||||
try {
|
try {
|
||||||
@@ -375,6 +477,20 @@ export function runUninstall(): void {
|
|||||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowedTools
|
||||||
|
try {
|
||||||
|
const removed = uninstallAllowedTools();
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||||
|
} else {
|
||||||
|
console.log("· No claudemesh allowedTools to remove");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
try {
|
try {
|
||||||
const removed = uninstallHooks();
|
const removed = uninstallHooks();
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||||
*
|
*
|
||||||
|
* Flags are defined in index.ts (citty command) — that is the source of
|
||||||
|
* truth. This file receives already-parsed flags and rawArgs.
|
||||||
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||||
* 2. If --join: run join flow first (accepts token or URL)
|
* 2. If --join: run join flow first
|
||||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||||
@@ -16,57 +19,19 @@ import { tmpdir, hostname } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { loadConfig, getConfigPath } from "../state/config";
|
import { loadConfig, getConfigPath } from "../state/config";
|
||||||
import type { Config, JoinedMesh } from "../state/config";
|
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||||
|
|
||||||
// --- Arg parsing ---
|
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||||
|
export interface LaunchFlags {
|
||||||
interface LaunchArgs {
|
name?: string;
|
||||||
name: string | null;
|
role?: string;
|
||||||
joinLink: string | null;
|
groups?: string;
|
||||||
meshSlug: string | null;
|
join?: string;
|
||||||
quiet: boolean;
|
mesh?: string;
|
||||||
skipPermConfirm: boolean;
|
"message-mode"?: string;
|
||||||
claudeArgs: string[];
|
"system-prompt"?: string;
|
||||||
}
|
yes?: boolean;
|
||||||
|
quiet?: boolean;
|
||||||
function parseArgs(argv: string[]): LaunchArgs {
|
|
||||||
const result: LaunchArgs = {
|
|
||||||
name: null,
|
|
||||||
joinLink: null,
|
|
||||||
meshSlug: null,
|
|
||||||
quiet: false,
|
|
||||||
skipPermConfirm: false,
|
|
||||||
claudeArgs: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
while (i < argv.length) {
|
|
||||||
const arg = argv[i]!;
|
|
||||||
if (arg === "--name" && i + 1 < argv.length) {
|
|
||||||
result.name = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--name=")) {
|
|
||||||
result.name = arg.slice("--name=".length);
|
|
||||||
} else if (arg === "--join" && i + 1 < argv.length) {
|
|
||||||
result.joinLink = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--join=")) {
|
|
||||||
result.joinLink = arg.slice("--join=".length);
|
|
||||||
} else if (arg === "--mesh" && i + 1 < argv.length) {
|
|
||||||
result.meshSlug = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--mesh=")) {
|
|
||||||
result.meshSlug = arg.slice("--mesh=".length);
|
|
||||||
} else if (arg === "--quiet") {
|
|
||||||
result.quiet = true;
|
|
||||||
} else if (arg === "-y" || arg === "--yes") {
|
|
||||||
result.skipPermConfirm = true;
|
|
||||||
} else if (arg === "--") {
|
|
||||||
result.claudeArgs.push(...argv.slice(i + 1));
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
result.claudeArgs.push(arg);
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Interactive mesh picker ---
|
// --- Interactive mesh picker ---
|
||||||
@@ -95,6 +60,33 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Group string parser ---
|
||||||
|
|
||||||
|
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
|
||||||
|
function parseGroupsString(raw: string): GroupEntry[] {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((token) => {
|
||||||
|
const idx = token.indexOf(":");
|
||||||
|
if (idx === -1) return { name: token };
|
||||||
|
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactive role/groups prompts ---
|
||||||
|
|
||||||
|
function askLine(prompt: string): Promise<string> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Permission confirmation ---
|
// --- Permission confirmation ---
|
||||||
|
|
||||||
async function confirmPermissions(): Promise<void> {
|
async function confirmPermissions(): Promise<void> {
|
||||||
@@ -106,12 +98,12 @@ async function confirmPermissions(): Promise<void> {
|
|||||||
|
|
||||||
console.log(yellow(bold(" Autonomous mode")));
|
console.log(yellow(bold(" Autonomous mode")));
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(" Claude will send and receive peer messages without asking");
|
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||||
console.log(" you first. Peers exchange text only — no file access,");
|
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||||
console.log(" no tool calls, no code execution.");
|
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||||
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
@@ -132,16 +124,27 @@ async function confirmPermissions(): Promise<void> {
|
|||||||
|
|
||||||
// --- Banner ---
|
// --- Banner ---
|
||||||
|
|
||||||
function printBanner(name: string, meshSlug: string): void {
|
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||||
const useColor =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const roleSuffix = role ? ` (${role})` : "";
|
||||||
|
const groupTags = groups.length
|
||||||
|
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||||
|
: "";
|
||||||
|
|
||||||
const rule = "─".repeat(60);
|
const rule = "─".repeat(60);
|
||||||
console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
|
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
if (messageMode === "push") {
|
||||||
|
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||||
|
} else if (messageMode === "inbox") {
|
||||||
|
console.log("Peer messages held in inbox. Use check_messages to read.");
|
||||||
|
} else {
|
||||||
|
console.log("Messages off. Use check_messages to poll manually.");
|
||||||
|
}
|
||||||
console.log("Peers send text only — they cannot call tools or read files.");
|
console.log("Peers send text only — they cannot call tools or read files.");
|
||||||
console.log(dim(`Config: ${getConfigPath()}`));
|
console.log(dim(`Config: ${getConfigPath()}`));
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
@@ -150,8 +153,26 @@ function printBanner(name: string, meshSlug: string): void {
|
|||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||||
const args = parseArgs(extraArgs);
|
// Extract args that follow "--" — passed straight through to claude.
|
||||||
|
const dashIdx = rawArgs.indexOf("--");
|
||||||
|
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||||
|
|
||||||
|
// Normalise flags into the internal shape used below.
|
||||||
|
const args = {
|
||||||
|
name: flags.name ?? null,
|
||||||
|
role: flags.role ?? null,
|
||||||
|
groups: flags.groups ?? null,
|
||||||
|
joinLink: flags.join ?? null,
|
||||||
|
meshSlug: flags.mesh ?? null,
|
||||||
|
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||||
|
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||||
|
: null),
|
||||||
|
systemPrompt: flags["system-prompt"] ?? null,
|
||||||
|
quiet: flags.quiet ?? false,
|
||||||
|
skipPermConfirm: flags.yes ?? false,
|
||||||
|
claudeArgs: claudePassthrough,
|
||||||
|
};
|
||||||
|
|
||||||
// 1. If --join, run join flow first.
|
// 1. If --join, run join flow first.
|
||||||
if (args.joinLink) {
|
if (args.joinLink) {
|
||||||
@@ -210,11 +231,41 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
mesh = await pickMesh(config.meshes);
|
mesh = await pickMesh(config.meshes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Session identity. The WS client auto-generates a per-session
|
// 3. Session identity + role/groups.
|
||||||
// ephemeral keypair on connect (sent in hello as sessionPubkey).
|
// The WS client auto-generates a per-session ephemeral keypair on
|
||||||
// We just set the display name via env var.
|
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
||||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
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)
|
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||||
const tmpBase = tmpdir();
|
const tmpBase = tmpdir();
|
||||||
try {
|
try {
|
||||||
@@ -232,6 +283,9 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
version: 1,
|
version: 1,
|
||||||
meshes: [mesh],
|
meshes: [mesh],
|
||||||
displayName,
|
displayName,
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||||
|
messageMode,
|
||||||
};
|
};
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(tmpDir, "config.json"),
|
join(tmpDir, "config.json"),
|
||||||
@@ -241,7 +295,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
|
|
||||||
// 5. Banner + permission confirmation.
|
// 5. Banner + permission confirmation.
|
||||||
if (!args.quiet) {
|
if (!args.quiet) {
|
||||||
printBanner(displayName, mesh.slug);
|
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||||
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||||
if (!args.skipPermConfirm) {
|
if (!args.skipPermConfirm) {
|
||||||
await confirmPermissions();
|
await confirmPermissions();
|
||||||
@@ -259,10 +313,15 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
filtered.push(args.claudeArgs[i]!);
|
filtered.push(args.claudeArgs[i]!);
|
||||||
}
|
}
|
||||||
|
// --dangerously-skip-permissions is only added when the user explicitly
|
||||||
|
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||||
|
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||||
|
// This keeps permissions tight for multi-person meshes.
|
||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
"--dangerously-skip-permissions",
|
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||||
|
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||||
...filtered,
|
...filtered,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -274,6 +333,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
...process.env,
|
...process.env,
|
||||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||||
|
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
63
apps/cli/src/commands/memory.ts
Normal file
63
apps/cli/src/commands/memory.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||||
|
* `claudemesh recall <query>` — search mesh memory.
|
||||||
|
*
|
||||||
|
* Useful for AI agents using bash when the MCP server isn't active.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
|
||||||
|
export interface MemoryFlags {
|
||||||
|
mesh?: string;
|
||||||
|
tags?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||||
|
const tags = flags.tags
|
||||||
|
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const id = await client.remember(content, tags);
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify({ id, content, tags }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||||
|
} else {
|
||||||
|
console.error("✗ Failed to store memory");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const memories = await client.recall(query);
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(memories, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memories.length === 0) {
|
||||||
|
console.log(dim("No memories found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of memories) {
|
||||||
|
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||||
|
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||||
|
console.log(` ${m.content}`);
|
||||||
|
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
48
apps/cli/src/commands/peers.ts
Normal file
48
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh peers` — list connected peers in the mesh.
|
||||||
|
*
|
||||||
|
* Connects, fetches the peer list, prints it, disconnects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
|
||||||
|
export interface PeersFlags {
|
||||||
|
mesh?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||||
|
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(peers, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||||
|
console.log("");
|
||||||
|
for (const p of peers) {
|
||||||
|
const groups = p.groups.length
|
||||||
|
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||||
|
: "";
|
||||||
|
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||||
|
const name = bold(p.displayName);
|
||||||
|
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||||
|
console.log(` ${statusIcon} ${name}${groups}${summary}`);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
});
|
||||||
|
}
|
||||||
134
apps/cli/src/commands/remind.ts
Normal file
134
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||||
|
* `claudemesh remind list`
|
||||||
|
* `claudemesh remind cancel <id>`
|
||||||
|
*
|
||||||
|
* Human-facing interface to the broker's scheduled message delivery.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
|
||||||
|
export interface RemindFlags {
|
||||||
|
mesh?: string;
|
||||||
|
in?: string; // e.g. "2h", "30m", "90s"
|
||||||
|
at?: string; // ISO or HH:MM
|
||||||
|
to?: string; // default: self
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDuration(raw: string): number | null {
|
||||||
|
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseFloat(m[1]!);
|
||||||
|
const unit = (m[2] ?? "s").toLowerCase();
|
||||||
|
if (unit.startsWith("d")) return n * 86_400_000;
|
||||||
|
if (unit.startsWith("h")) return n * 3_600_000;
|
||||||
|
if (unit.startsWith("m")) return n * 60_000;
|
||||||
|
return n * 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||||
|
if (flags.in) {
|
||||||
|
const ms = parseDuration(flags.in);
|
||||||
|
if (ms === null) return null;
|
||||||
|
return Date.now() + ms;
|
||||||
|
}
|
||||||
|
if (flags.at) {
|
||||||
|
// Try HH:MM first
|
||||||
|
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (hm) {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date(now);
|
||||||
|
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||||
|
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||||
|
return target.getTime();
|
||||||
|
}
|
||||||
|
const ts = Date.parse(flags.at);
|
||||||
|
return isNaN(ts) ? null : ts;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRemind(
|
||||||
|
flags: RemindFlags,
|
||||||
|
positional: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const action = positional[0];
|
||||||
|
|
||||||
|
// claudemesh remind list
|
||||||
|
if (action === "list") {
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const scheduled = await client.listScheduled();
|
||||||
|
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||||
|
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||||
|
for (const m of scheduled) {
|
||||||
|
const when = new Date(m.deliverAt).toLocaleString();
|
||||||
|
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||||
|
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||||
|
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudemesh remind cancel <id>
|
||||||
|
if (action === "cancel") {
|
||||||
|
const id = positional[1];
|
||||||
|
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const ok = await client.cancelScheduled(id);
|
||||||
|
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||||
|
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudemesh remind <message> --in <duration> | --at <time>
|
||||||
|
const message = action ?? positional.join(" ");
|
||||||
|
if (!message) {
|
||||||
|
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||||
|
console.error(" claudemesh remind <message> --at <time>");
|
||||||
|
console.error(" claudemesh remind list");
|
||||||
|
console.error(" claudemesh remind cancel <id>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliverAt = parseDeliverAt(flags);
|
||||||
|
if (deliverAt === null) {
|
||||||
|
console.error('Specify when: --in <duration> (e.g. "2h", "30m") or --at <time> (e.g. "15:00")');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
// Determine target: --to flag or self
|
||||||
|
let targetSpec: string;
|
||||||
|
if (flags.to && flags.to !== "self") {
|
||||||
|
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||||
|
targetSpec = flags.to;
|
||||||
|
} else {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||||
|
if (!match) {
|
||||||
|
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
targetSpec = match.pubkey;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
targetSpec = client.getSessionPubkey() ?? "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.scheduleMessage(targetSpec, message, deliverAt);
|
||||||
|
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||||
|
|
||||||
|
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||||
|
const when = new Date(result.deliverAt).toLocaleString();
|
||||||
|
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||||
|
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
51
apps/cli/src/commands/send.ts
Normal file
51
apps/cli/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||||
|
*
|
||||||
|
* <to> can be:
|
||||||
|
* - a display name ("Mou")
|
||||||
|
* - a pubkey hex ("abc123...")
|
||||||
|
* - @group ("@flexicar")
|
||||||
|
* - * (broadcast to all)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
import type { Priority } from "../ws/client";
|
||||||
|
|
||||||
|
export interface SendFlags {
|
||||||
|
mesh?: string;
|
||||||
|
priority?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||||
|
const priority: Priority =
|
||||||
|
flags.priority === "now" ? "now"
|
||||||
|
: flags.priority === "low" ? "low"
|
||||||
|
: "next";
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
// Resolve display name → pubkey for direct messages.
|
||||||
|
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||||
|
let targetSpec = to;
|
||||||
|
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||||
|
// Treat as display name — look up pubkey via list_peers.
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const match = peers.find(
|
||||||
|
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
const names = peers.map((p) => p.displayName).join(", ");
|
||||||
|
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
targetSpec = match.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.send(targetSpec, message, priority);
|
||||||
|
if (result.ok) {
|
||||||
|
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||||
|
} else {
|
||||||
|
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
75
apps/cli/src/commands/state.ts
Normal file
75
apps/cli/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh state get <key>` — read a shared state value
|
||||||
|
* `claudemesh state set <key> <value>` — write a shared state value
|
||||||
|
* `claudemesh state list` — list all state entries
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
|
||||||
|
export interface StateFlags {
|
||||||
|
mesh?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const entry = await client.getState(key);
|
||||||
|
if (!entry) {
|
||||||
|
console.log(dim(`(not set)`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(entry, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||||
|
console.log(val);
|
||||||
|
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||||
|
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
parsed = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
await client.setState(key, parsed);
|
||||||
|
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
|
const entries = await client.listState();
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(entries, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||||
|
console.log(`${bold(e.key)}: ${val}`);
|
||||||
|
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
90
apps/cli/src/crypto/file-crypto.ts
Normal file
90
apps/cli/src/crypto/file-crypto.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* File encryption for claudemesh E2E file sharing.
|
||||||
|
*
|
||||||
|
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
|
||||||
|
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
|
||||||
|
* Key opening: crypto_box_seal_open with own X25519 keypair.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureSodium } from "./keypair";
|
||||||
|
|
||||||
|
export interface EncryptedFile {
|
||||||
|
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
|
||||||
|
nonce: string; // base64 24-byte nonce
|
||||||
|
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt file bytes with a fresh random symmetric key.
|
||||||
|
* Returns ciphertext, nonce (base64), and the plaintext Kf.
|
||||||
|
*/
|
||||||
|
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
|
||||||
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt file bytes with the symmetric key Kf.
|
||||||
|
* Returns null if decryption fails.
|
||||||
|
*/
|
||||||
|
export async function decryptFile(
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
nonceB64: string,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
|
||||||
|
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
|
||||||
|
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
|
||||||
|
* Returns base64 sealed box.
|
||||||
|
*/
|
||||||
|
export async function sealKeyForPeer(
|
||||||
|
kf: Uint8Array,
|
||||||
|
recipientPubkeyHex: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(recipientPubkeyHex),
|
||||||
|
);
|
||||||
|
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
|
||||||
|
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
|
||||||
|
* Returns the 32-byte Kf or null if decryption fails.
|
||||||
|
*/
|
||||||
|
export async function openSealedKey(
|
||||||
|
sealedB64: string,
|
||||||
|
myPubkeyHex: string,
|
||||||
|
mySecretKeyHex: string,
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(myPubkeyHex),
|
||||||
|
);
|
||||||
|
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
sodium.from_hex(mySecretKeyHex),
|
||||||
|
);
|
||||||
|
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
|
||||||
|
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* claudemesh-cli entry point.
|
* claudemesh-cli entry point.
|
||||||
*
|
*
|
||||||
|
* Uses citty to define commands and flags. --help is generated from
|
||||||
|
* the command definitions — the flag list here IS the documentation.
|
||||||
|
*
|
||||||
* Dispatches between two modes:
|
* Dispatches between two modes:
|
||||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||||
* - `claudemesh <subcommand>` → CLI subcommand
|
* - `claudemesh <subcommand>` → CLI subcommand
|
||||||
*
|
|
||||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { defineCommand, runMain } from "citty";
|
||||||
import { startMcpServer } from "./mcp/server";
|
import { startMcpServer } from "./mcp/server";
|
||||||
import { runInstall, runUninstall } from "./commands/install";
|
import { runInstall, runUninstall } from "./commands/install";
|
||||||
import { runJoin } from "./commands/join";
|
import { runJoin } from "./commands/join";
|
||||||
@@ -19,98 +21,258 @@ import { runLaunch } from "./commands/launch";
|
|||||||
import { runStatus } from "./commands/status";
|
import { runStatus } from "./commands/status";
|
||||||
import { runDoctor } from "./commands/doctor";
|
import { runDoctor } from "./commands/doctor";
|
||||||
import { runWelcome } from "./commands/welcome";
|
import { runWelcome } from "./commands/welcome";
|
||||||
|
import { runPeers } from "./commands/peers";
|
||||||
|
import { runSend } from "./commands/send";
|
||||||
|
import { runInbox } from "./commands/inbox";
|
||||||
|
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||||
|
import { runRemember, runRecall } from "./commands/memory";
|
||||||
|
import { runInfo } from "./commands/info";
|
||||||
|
import { runRemind } from "./commands/remind";
|
||||||
import { VERSION } from "./version";
|
import { VERSION } from "./version";
|
||||||
|
|
||||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
const launch = defineCommand({
|
||||||
|
meta: {
|
||||||
Usage:
|
name: "launch",
|
||||||
claudemesh <command> [args]
|
description: "Launch Claude Code connected to a mesh with real-time peer messaging",
|
||||||
|
},
|
||||||
Commands:
|
args: {
|
||||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
name: {
|
||||||
(add --no-hooks for bare MCP registration)
|
type: "string",
|
||||||
uninstall Remove MCP server + hooks
|
description: "Display name for this session",
|
||||||
launch [opts] Launch Claude Code with real-time push messages
|
},
|
||||||
--name <name> Display name for this session
|
role: {
|
||||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
type: "string",
|
||||||
--join <url> Join a mesh before launching
|
description: "Role tag (dev, lead, analyst — free-form)",
|
||||||
--quiet Skip the info banner
|
},
|
||||||
-- <args> Pass remaining args to claude
|
groups: {
|
||||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
type: "string",
|
||||||
list Show all joined meshes
|
description: 'Groups to join: "group:role,group2" — colon sets role. Hierarchy via slash: "eng/frontend:lead"',
|
||||||
leave <slug> Leave a joined mesh
|
},
|
||||||
status Health report: broker reachability per joined mesh
|
mesh: {
|
||||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
type: "string",
|
||||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
description: "Select mesh by slug (interactive picker if omitted and >1 joined)",
|
||||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
},
|
||||||
--help, -h Show this help
|
join: {
|
||||||
--version, -v Show the CLI version
|
type: "string",
|
||||||
|
description: "Join a mesh via invite URL before launching",
|
||||||
Environment:
|
},
|
||||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
"message-mode": {
|
||||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
type: "string",
|
||||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
description: "push (default) | inbox | off — controls how peer messages are delivered",
|
||||||
`;
|
},
|
||||||
|
"system-prompt": {
|
||||||
const cmd = process.argv[2];
|
type: "string",
|
||||||
const args = process.argv.slice(3);
|
description: "Set Claude's system prompt for this session",
|
||||||
|
},
|
||||||
async function main(): Promise<void> {
|
yes: {
|
||||||
switch (cmd) {
|
type: "boolean",
|
||||||
case "mcp":
|
alias: "y",
|
||||||
await startMcpServer();
|
description: "Skip permission confirmation",
|
||||||
return;
|
default: false,
|
||||||
case "install":
|
},
|
||||||
runInstall(args);
|
quiet: {
|
||||||
return;
|
type: "boolean",
|
||||||
case "uninstall":
|
description: "Skip banner and all interactive prompts",
|
||||||
runUninstall();
|
default: false,
|
||||||
return;
|
},
|
||||||
case "hook":
|
},
|
||||||
await runHook(args);
|
run({ args, rawArgs }) {
|
||||||
return;
|
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||||
case "launch":
|
return runLaunch(args, rawArgs);
|
||||||
await runLaunch(args);
|
},
|
||||||
return;
|
|
||||||
case "join":
|
|
||||||
await runJoin(args);
|
|
||||||
return;
|
|
||||||
case "list":
|
|
||||||
runList();
|
|
||||||
return;
|
|
||||||
case "leave":
|
|
||||||
runLeave(args);
|
|
||||||
return;
|
|
||||||
case "status":
|
|
||||||
await runStatus();
|
|
||||||
return;
|
|
||||||
case "doctor":
|
|
||||||
await runDoctor();
|
|
||||||
return;
|
|
||||||
case "seed-test-mesh":
|
|
||||||
runSeedTestMesh(args);
|
|
||||||
return;
|
|
||||||
case "--version":
|
|
||||||
case "-v":
|
|
||||||
case "version":
|
|
||||||
console.log(VERSION);
|
|
||||||
return;
|
|
||||||
case "--help":
|
|
||||||
case "-h":
|
|
||||||
case "help":
|
|
||||||
console.log(HELP);
|
|
||||||
return;
|
|
||||||
case undefined:
|
|
||||||
runWelcome();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
console.error(`Unknown command: ${cmd}`);
|
|
||||||
console.error("Run `claudemesh --help` for usage.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const install = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "install",
|
||||||
|
description: "Register MCP server + status hooks with Claude Code",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
"no-hooks": {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Register MCP server only, skip hooks",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ rawArgs }) {
|
||||||
|
runInstall(rawArgs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const join = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "join",
|
||||||
|
description: "Join a mesh via invite URL",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
url: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Invite URL (https://claudemesh.com/join/...)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ args }) {
|
||||||
|
return runJoin([args.url]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leave = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "leave",
|
||||||
|
description: "Leave a joined mesh",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
slug: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Mesh slug to leave",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ args }) {
|
||||||
|
runLeave([args.slug]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "claudemesh",
|
||||||
|
version: VERSION,
|
||||||
|
description: "Peer mesh for Claude Code sessions",
|
||||||
|
},
|
||||||
|
subCommands: {
|
||||||
|
launch,
|
||||||
|
install,
|
||||||
|
uninstall: defineCommand({
|
||||||
|
meta: { name: "uninstall", description: "Remove MCP server and hooks" },
|
||||||
|
run() { runUninstall(); },
|
||||||
|
}),
|
||||||
|
join,
|
||||||
|
list: defineCommand({
|
||||||
|
meta: { name: "list", description: "Show joined meshes and identities" },
|
||||||
|
run() { runList(); },
|
||||||
|
}),
|
||||||
|
leave,
|
||||||
|
peers: defineCommand({
|
||||||
|
meta: { name: "peers", description: "List connected peers in the mesh" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runPeers(args); },
|
||||||
|
}),
|
||||||
|
send: defineCommand({
|
||||||
|
meta: { name: "send", description: "Send a message to a peer, group, or broadcast" },
|
||||||
|
args: {
|
||||||
|
to: { type: "positional", description: "Recipient: display name, @group, pubkey, or *", required: true },
|
||||||
|
message: { type: "positional", description: "Message text", required: true },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
priority: { type: "string", description: "now | next (default) | low" },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||||
|
}),
|
||||||
|
inbox: defineCommand({
|
||||||
|
meta: { name: "inbox", description: "Read pending peer messages" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
wait: { type: "string", description: "Seconds to wait for broker delivery (default: 1)" },
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
state: defineCommand({
|
||||||
|
meta: { name: "state", description: "Read or write shared mesh state" },
|
||||||
|
args: {
|
||||||
|
action: { type: "positional", description: "get | set | list", required: true },
|
||||||
|
key: { type: "positional", description: "State key (required for get/set)" },
|
||||||
|
value: { type: "positional", description: "Value to set (required for set)" },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
if (args.action === "list") {
|
||||||
|
await runStateList(args);
|
||||||
|
} else if (args.action === "get") {
|
||||||
|
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||||
|
await runStateGet(args, args.key);
|
||||||
|
} else if (args.action === "set") {
|
||||||
|
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||||
|
await runStateSet(args, args.key, args.value);
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
info: defineCommand({
|
||||||
|
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runInfo(args); },
|
||||||
|
}),
|
||||||
|
remember: defineCommand({
|
||||||
|
meta: { name: "remember", description: "Store a memory in the mesh (accessible to all peers)" },
|
||||||
|
args: {
|
||||||
|
content: { type: "positional", description: "Text to remember", required: true },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
tags: { type: "string", description: "Comma-separated tags (e.g. task,context)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runRemember(args, args.content); },
|
||||||
|
}),
|
||||||
|
recall: defineCommand({
|
||||||
|
meta: { name: "recall", description: "Search mesh memory by keyword or phrase" },
|
||||||
|
args: {
|
||||||
|
query: { type: "positional", description: "Search query", required: true },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runRecall(args, args.query); },
|
||||||
|
}),
|
||||||
|
remind: defineCommand({
|
||||||
|
meta: { name: "remind", description: "Schedule a reminder or delayed message via the broker" },
|
||||||
|
args: {
|
||||||
|
message: { type: "positional", description: "Message text, or: list | cancel <id>", required: false },
|
||||||
|
extra: { type: "positional", description: "Additional positional args", required: false },
|
||||||
|
in: { type: "string", description: 'Deliver after duration: "2h", "30m", "90s"' },
|
||||||
|
at: { type: "string", description: 'Deliver at time: "15:00" or ISO timestamp' },
|
||||||
|
to: { type: "string", description: "Recipient (default: self). Name, @group, pubkey, or *" },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args, rawArgs }) {
|
||||||
|
// Collect positional args from rawArgs (before any flags)
|
||||||
|
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||||
|
await runRemind(args, positionals);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
status: defineCommand({
|
||||||
|
meta: { name: "status", description: "Check broker reachability for each joined mesh" },
|
||||||
|
async run() { await runStatus(); },
|
||||||
|
}),
|
||||||
|
doctor: defineCommand({
|
||||||
|
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH" },
|
||||||
|
async run() { await runDoctor(); },
|
||||||
|
}),
|
||||||
|
mcp: defineCommand({
|
||||||
|
meta: { name: "mcp", description: "Start MCP server (stdio — invoked by Claude Code, not users)" },
|
||||||
|
async run() { await startMcpServer(); },
|
||||||
|
}),
|
||||||
|
"seed-test-mesh": defineCommand({
|
||||||
|
meta: { name: "seed-test-mesh", description: "Dev only: inject a mesh into config (skips invite flow)" },
|
||||||
|
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||||
|
}),
|
||||||
|
hook: defineCommand({
|
||||||
|
meta: { name: "hook", description: "Internal hook handler (invoked by Claude Code hooks)" },
|
||||||
|
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
runWelcome();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runMain(main);
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ async function resolveClient(to: string): Promise<{
|
|||||||
target = rest;
|
target = rest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pubkey, channel, or broadcast — pass through directly.
|
// Pubkey, channel, @group, or broadcast — pass through directly.
|
||||||
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target === "*") {
|
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target.startsWith("@") || target === "*") {
|
||||||
if (targetClients.length === 1) {
|
if (targetClients.length === 1) {
|
||||||
return { client: targetClients[0]!, targetSpec: target };
|
return { client: targetClients[0]!, targetSpec: target };
|
||||||
}
|
}
|
||||||
@@ -123,38 +123,134 @@ function decryptFailedWarning(senderPubkey: string): string {
|
|||||||
|
|
||||||
function formatPush(p: InboundPush, meshSlug: string): string {
|
function formatPush(p: InboundPush, meshSlug: string): string {
|
||||||
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
|
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
|
||||||
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
const tag = p.subtype === "reminder" ? " [REMINDER]" : "";
|
||||||
|
return `[${meshSlug}]${tag} from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMcpServer(): Promise<void> {
|
export async function startMcpServer(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const myName = config.displayName ?? "unnamed";
|
||||||
|
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
|
||||||
|
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
|
||||||
|
const messageMode = config.messageMode ?? "push";
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: "claudemesh", version: "0.1.4" },
|
{ name: "claudemesh", version: "0.3.0" },
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
experimental: { "claude/channel": {} },
|
experimental: { "claude/channel": {} },
|
||||||
tools: {},
|
tools: {},
|
||||||
},
|
},
|
||||||
instructions: `You are connected to claudemesh — a peer mesh for Claude Code sessions on this machine and elsewhere.
|
instructions: `## Identity
|
||||||
|
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
||||||
|
|
||||||
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 to set to the from_name (display name) of the sender.
|
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
|
||||||
|
|
||||||
Available tools:
|
## Tools
|
||||||
- list_peers: see joined meshes + their connection status
|
| Tool | Description |
|
||||||
- send_message: send to a peer by display name, pubkey, #channel, or * broadcast (priority: now/next/low)
|
|------|-------------|
|
||||||
- check_messages: drain buffered inbound messages (usually auto-pushed)
|
| send_message(to, message, priority?) | Send to peer name, @group, or * broadcast. \`to\` accepts display name, pubkey hex, @groupname, or *. |
|
||||||
- set_summary: 1-2 sentence summary of what you're working on
|
| list_peers(mesh_slug?) | List connected peers with status, summary, groups, and roles. |
|
||||||
- set_status: manually override your status (idle/working/dnd)
|
| 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. |
|
||||||
|
| schedule_reminder(message, in_seconds?, deliver_at?, to?) | Schedule a reminder to yourself (no \`to\`) or a delayed message to a peer/group. Delivered as a push with \`subtype: reminder\` in the channel meta. |
|
||||||
|
| list_scheduled() | List pending scheduled reminders and messages. |
|
||||||
|
| cancel_scheduled(id) | Cancel a pending scheduled item. |
|
||||||
|
|
||||||
Message priority:
|
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
|
||||||
- "now": delivered immediately regardless of recipient status (use sparingly)
|
|
||||||
- "next" (default): delivered when recipient is idle
|
|
||||||
- "low": pull-only (check_messages)
|
|
||||||
|
|
||||||
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.`,
|
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.
|
||||||
|
|
||||||
|
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.`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -176,22 +272,32 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
|
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
|
||||||
if (!to || !message)
|
if (!to || !message)
|
||||||
return text("send_message: `to` and `message` required", true);
|
return text("send_message: `to` and `message` required", true);
|
||||||
const { client, targetSpec, error } = await resolveClient(to);
|
|
||||||
if (!client)
|
// Handle multi-target: to can be string or string[]
|
||||||
return text(`send_message: ${error ?? "no client resolved"}`, true);
|
const targets = Array.isArray(to) ? to : [to];
|
||||||
const result = await client.send(
|
const results: string[] = [];
|
||||||
targetSpec,
|
const seen = new Set<string>(); // dedup by resolved pubkey
|
||||||
message,
|
|
||||||
(priority ?? "next") as Priority,
|
for (const target of targets) {
|
||||||
);
|
const { client, targetSpec, error } = await resolveClient(target);
|
||||||
if (!result.ok)
|
if (!client) {
|
||||||
return text(
|
results.push(`✗ ${target}: ${error ?? "no client resolved"}`);
|
||||||
`send_message failed (${client.meshSlug}): ${result.error}`,
|
continue;
|
||||||
true,
|
}
|
||||||
|
if (seen.has(targetSpec)) continue; // dedup
|
||||||
|
seen.add(targetSpec);
|
||||||
|
const result = await client.send(
|
||||||
|
targetSpec,
|
||||||
|
message,
|
||||||
|
(priority ?? "next") as Priority,
|
||||||
);
|
);
|
||||||
return text(
|
if (!result.ok) {
|
||||||
`Sent to ${targetSpec} via ${client.meshSlug} [${priority ?? "next"}] → ${result.messageId}`,
|
results.push(`✗ ${target}: ${result.error}`);
|
||||||
);
|
} else {
|
||||||
|
results.push(`✓ ${target} → ${result.messageId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text(results.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list_peers": {
|
case "list_peers": {
|
||||||
@@ -215,7 +321,8 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
} else {
|
} else {
|
||||||
const peerLines = peers.map((p) => {
|
const peerLines = peers.map((p) => {
|
||||||
const summary = p.summary ? ` — "${p.summary}"` : "";
|
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")}`);
|
sections.push(`${header}\n${peerLines.join("\n")}`);
|
||||||
}
|
}
|
||||||
@@ -223,6 +330,30 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
return text(sections.join("\n\n"));
|
return text(sections.join("\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "message_status": {
|
||||||
|
const { id } = (args ?? {}) as { id?: string };
|
||||||
|
if (!id) return text("message_status: `id` required", true);
|
||||||
|
const clients = allClients();
|
||||||
|
if (!clients.length) return text("message_status: not connected", true);
|
||||||
|
// Try each connected mesh client — we don't know which mesh the
|
||||||
|
// messageId belongs to, so query all and return the first hit.
|
||||||
|
let result = null;
|
||||||
|
for (const c of clients) {
|
||||||
|
result = await c.messageStatus(id);
|
||||||
|
if (result) break;
|
||||||
|
}
|
||||||
|
if (!result) return text(`Message ${id} not found or timed out.`);
|
||||||
|
const recipientLines = result.recipients.map(
|
||||||
|
(r: { name: string; pubkey: string; status: string }) =>
|
||||||
|
` - ${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": {
|
case "check_messages": {
|
||||||
const drained: string[] = [];
|
const drained: string[] = [];
|
||||||
for (const c of allClients()) {
|
for (const c of allClients()) {
|
||||||
@@ -252,6 +383,590 @@ 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).`);
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scheduled messages ---
|
||||||
|
case "schedule_reminder": {
|
||||||
|
const sArgs = (args ?? {}) as {
|
||||||
|
message?: string;
|
||||||
|
to?: string;
|
||||||
|
deliver_at?: number;
|
||||||
|
in_seconds?: number;
|
||||||
|
};
|
||||||
|
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
|
||||||
|
|
||||||
|
let deliverAt: number;
|
||||||
|
if (sArgs.deliver_at) {
|
||||||
|
deliverAt = Number(sArgs.deliver_at);
|
||||||
|
} else if (sArgs.in_seconds) {
|
||||||
|
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
|
||||||
|
} else {
|
||||||
|
return text("schedule_reminder: provide `deliver_at` (ms timestamp) or `in_seconds`", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = !sArgs.to;
|
||||||
|
let targetSpec: string;
|
||||||
|
if (isSelf) {
|
||||||
|
// Self-reminder: target own session pubkey
|
||||||
|
targetSpec = client.getSessionPubkey() ?? "*";
|
||||||
|
} else {
|
||||||
|
const to = sArgs.to!;
|
||||||
|
// Resolve display name → pubkey if not a raw spec
|
||||||
|
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
|
||||||
|
if (!match) {
|
||||||
|
const names = peers.map((p) => p.displayName).join(", ");
|
||||||
|
return text(`schedule_reminder: peer "${to}" not found. Online: ${names || "(none)"}`, true);
|
||||||
|
}
|
||||||
|
targetSpec = match.pubkey;
|
||||||
|
} else {
|
||||||
|
targetSpec = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true);
|
||||||
|
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
|
||||||
|
const when = new Date(result.deliverAt).toISOString();
|
||||||
|
return text(
|
||||||
|
isSelf
|
||||||
|
? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
|
||||||
|
: `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "list_scheduled": {
|
||||||
|
const scheduled = await client.listScheduled();
|
||||||
|
if (scheduled.length === 0) return text("No pending scheduled messages.");
|
||||||
|
const lines = scheduled.map((m) =>
|
||||||
|
`- [${m.id.slice(0, 8)}] → ${m.to === client.getSessionPubkey() ? "self (reminder)" : m.to} at ${new Date(m.deliverAt).toISOString()}: "${m.message.slice(0, 60)}${m.message.length > 60 ? "…" : ""}"`,
|
||||||
|
);
|
||||||
|
return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`);
|
||||||
|
}
|
||||||
|
case "cancel_scheduled": {
|
||||||
|
const { id: schedId } = (args ?? {}) as { id?: string };
|
||||||
|
if (!schedId) return text("cancel_scheduled: `id` required", true);
|
||||||
|
const ok = await client.cancelScheduled(schedId);
|
||||||
|
return text(ok ? `Cancelled: ${schedId}` : `Not found or already fired: ${schedId}`, !ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Files ---
|
||||||
|
case "share_file": {
|
||||||
|
const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string };
|
||||||
|
if (!filePath) return text("share_file: `path` required", true);
|
||||||
|
const { existsSync } = await import("node:fs");
|
||||||
|
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) return text("share_file: not connected", true);
|
||||||
|
|
||||||
|
// If 'to' specified, do E2E encryption
|
||||||
|
if (fileTo) {
|
||||||
|
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
|
||||||
|
const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs");
|
||||||
|
const { tmpdir } = await import("node:os");
|
||||||
|
const { join, basename } = await import("node:path");
|
||||||
|
|
||||||
|
// Resolve target peer pubkey
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const targetPeer = peers.find(p => p.pubkey === fileTo || p.displayName === fileTo);
|
||||||
|
if (!targetPeer) {
|
||||||
|
return text(`share_file: peer not found: ${fileTo}`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and encrypt file
|
||||||
|
const plaintext = readFileSync(filePath);
|
||||||
|
const { ciphertext, nonce, key } = await encryptFile(new Uint8Array(plaintext));
|
||||||
|
|
||||||
|
// Seal Kf for target peer
|
||||||
|
const sealedForTarget = await sealKeyForPeer(key, targetPeer.pubkey);
|
||||||
|
|
||||||
|
// Seal Kf for ourselves (owner)
|
||||||
|
const myPubkey = client.getSessionPubkey();
|
||||||
|
const sealedForSelf = myPubkey ? await sealKeyForPeer(key, myPubkey) : null;
|
||||||
|
|
||||||
|
const fileKeys = [
|
||||||
|
{ peerPubkey: targetPeer.pubkey, sealedKey: sealedForTarget },
|
||||||
|
...(sealedForSelf && myPubkey ? [{ peerPubkey: myPubkey, sealedKey: sealedForSelf }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build combined buffer: nonce (24 bytes) + ciphertext
|
||||||
|
const { ensureSodium } = await import("../crypto/keypair");
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL);
|
||||||
|
const combined = new Uint8Array(nonceBytes.length + ciphertext.length);
|
||||||
|
combined.set(nonceBytes, 0);
|
||||||
|
combined.set(ciphertext, nonceBytes.length);
|
||||||
|
|
||||||
|
const baseName = fileName ?? basename(filePath);
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "cm-"));
|
||||||
|
const tmpPath = join(tmpDir, baseName);
|
||||||
|
writeFileSync(tmpPath, combined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileId = await client.uploadFile(tmpPath, client.meshId, client.meshSlug, {
|
||||||
|
name: baseName,
|
||||||
|
tags,
|
||||||
|
persistent: true,
|
||||||
|
encrypted: true,
|
||||||
|
ownerPubkey: myPubkey ?? undefined,
|
||||||
|
fileKeys,
|
||||||
|
});
|
||||||
|
return text(`Shared (E2E encrypted): ${baseName} → ${targetPeer.displayName} (${fileId})`);
|
||||||
|
} catch (e) {
|
||||||
|
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
try { rmdirSync(tmpDir); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain (unencrypted) upload — existing code
|
||||||
|
try {
|
||||||
|
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
|
||||||
|
name: fileName, tags, persistent: true,
|
||||||
|
});
|
||||||
|
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
|
||||||
|
} catch (e) {
|
||||||
|
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get_file": {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (result.encrypted) {
|
||||||
|
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
|
||||||
|
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
|
||||||
|
const { ensureSodium } = await import("../crypto/keypair");
|
||||||
|
const myPubkey = client.getSessionPubkey();
|
||||||
|
const mySecret = client.getSessionSecretKey();
|
||||||
|
|
||||||
|
if (!myPubkey || !mySecret) {
|
||||||
|
return text("get_file: no session keypair — cannot decrypt", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
|
||||||
|
if (!kf) return text("get_file: failed to open sealed key", true);
|
||||||
|
|
||||||
|
// Download file bytes from presigned URL
|
||||||
|
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
|
||||||
|
const buf = new Uint8Array(await resp.arrayBuffer());
|
||||||
|
|
||||||
|
// Wire format: first 24 bytes = nonce, rest = ciphertext
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
|
||||||
|
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
|
||||||
|
const ciphertext = buf.slice(NONCE_BYTES);
|
||||||
|
|
||||||
|
const plaintext = await decryptFile(ciphertext, nonce, kf);
|
||||||
|
if (!plaintext) return text("get_file: decryption failed", true);
|
||||||
|
|
||||||
|
const { writeFileSync, mkdirSync } = await import("node:fs");
|
||||||
|
const { dirname } = await import("node:path");
|
||||||
|
mkdirSync(dirname(save_to), { recursive: true });
|
||||||
|
writeFileSync(save_to, plaintext);
|
||||||
|
return text(`Downloaded and decrypted: ${result.name} → ${save_to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unencrypted — existing download logic
|
||||||
|
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
|
||||||
|
const { writeFileSync, mkdirSync } = await import("node:fs");
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ping_mesh": {
|
||||||
|
const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] };
|
||||||
|
const toTest = (pingPriorities ?? ["now", "next"]) as Priority[];
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) return text("ping_mesh: not connected", true);
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
// Diagnostics: connection state
|
||||||
|
results.push(`WS status: ${client.status}`);
|
||||||
|
results.push(`Mesh: ${client.meshSlug}`);
|
||||||
|
|
||||||
|
// Check own peer status (explains priority gating)
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const selfPeer = peers.find(p => p.displayName === myName);
|
||||||
|
results.push(`Your status: ${selfPeer?.status ?? "not found in peer list"}`);
|
||||||
|
results.push(`Peers online: ${peers.length}`);
|
||||||
|
results.push(`Push buffer: ${client.pushHistory.length} buffered`);
|
||||||
|
|
||||||
|
// Test send→ack latency per priority (doesn't need round-trip)
|
||||||
|
for (const prio of toTest) {
|
||||||
|
const sendTime = Date.now();
|
||||||
|
// Send to a peer if one exists, otherwise broadcast
|
||||||
|
const target = peers.find(p => p.displayName !== myName);
|
||||||
|
const sendResult = await client.send(
|
||||||
|
target?.pubkey ?? "*",
|
||||||
|
`__ping__ ${prio} from ${myName} at ${new Date().toISOString()}`,
|
||||||
|
prio,
|
||||||
|
);
|
||||||
|
const ackTime = Date.now();
|
||||||
|
|
||||||
|
if (!sendResult.ok) {
|
||||||
|
results.push(`[${prio}] SEND FAILED: ${sendResult.error}`);
|
||||||
|
} else {
|
||||||
|
results.push(`[${prio}] send→ack: ${ackTime - sendTime}ms (msgId: ${sendResult.messageId?.slice(0, 12)})`);
|
||||||
|
if (prio !== "now" && selfPeer?.status === "working") {
|
||||||
|
results.push(` ⚠ peer status is "working" — broker holds "${prio}" until idle`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if notification pipeline works
|
||||||
|
results.push("");
|
||||||
|
results.push("Pipeline check:");
|
||||||
|
results.push(` onPush handlers: active`);
|
||||||
|
results.push(` messageMode: ${messageMode}`);
|
||||||
|
results.push(` server.notification: ${messageMode === "off" ? "disabled (mode=off)" : "enabled"}`);
|
||||||
|
|
||||||
|
return text(results.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "grant_file_access": {
|
||||||
|
const { fileId, to: grantTo } = (args ?? {}) as { fileId?: string; to?: string };
|
||||||
|
if (!fileId || !grantTo) return text("grant_file_access: `fileId` and `to` required", true);
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client) return text("grant_file_access: not connected", true);
|
||||||
|
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const targetPeer = peers.find(p => p.pubkey === grantTo || p.displayName === grantTo);
|
||||||
|
if (!targetPeer) return text(`grant_file_access: peer not found: ${grantTo}`, true);
|
||||||
|
|
||||||
|
const result = await client.getFile(fileId);
|
||||||
|
if (!result) return text("grant_file_access: file not found", true);
|
||||||
|
if (!result.encrypted) return text("grant_file_access: file is not encrypted", true);
|
||||||
|
if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true);
|
||||||
|
|
||||||
|
const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto");
|
||||||
|
const myPubkey = client.getSessionPubkey();
|
||||||
|
const mySecret = client.getSessionSecretKey();
|
||||||
|
if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true);
|
||||||
|
|
||||||
|
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
|
||||||
|
if (!kf) return text("grant_file_access: cannot decrypt your own key", true);
|
||||||
|
|
||||||
|
const sealedForPeer = await sealKeyForPeer(kf, targetPeer.pubkey);
|
||||||
|
const ok = await client.grantFileAccess(fileId, targetPeer.pubkey, sealedForPeer);
|
||||||
|
|
||||||
|
if (!ok) return text("grant_file_access: broker did not confirm", true);
|
||||||
|
return text(`Access granted: ${targetPeer.displayName} can now download file ${fileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return text(`Unknown tool: ${name}`, true);
|
return text(`Unknown tool: ${name}`, true);
|
||||||
}
|
}
|
||||||
@@ -267,12 +982,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">
|
// any mesh's broker connection becomes a <channel source="claudemesh">
|
||||||
// system reminder injected into Claude Code's context.
|
// system reminder injected into Claude Code's context.
|
||||||
for (const client of allClients()) {
|
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) => {
|
client.onPush(async (msg) => {
|
||||||
|
if (messageMode === "off") return;
|
||||||
|
|
||||||
const fromPubkey = msg.senderPubkey || "";
|
const fromPubkey = msg.senderPubkey || "";
|
||||||
// Resolve sender's display name from the cached peer list.
|
|
||||||
const fromName = fromPubkey
|
const fromName = fromPubkey
|
||||||
? await resolvePeerName(client, fromPubkey)
|
? await resolvePeerName(client, fromPubkey)
|
||||||
: "unknown";
|
: "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);
|
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
|
||||||
try {
|
try {
|
||||||
await server.notification({
|
await server.notification({
|
||||||
@@ -288,16 +1024,85 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
sent_at: msg.createdAt,
|
sent_at: msg.createdAt,
|
||||||
delivered_at: msg.receivedAt,
|
delivered_at: msg.receivedAt,
|
||||||
kind: msg.kind,
|
kind: msg.kind,
|
||||||
|
...(msg.subtype ? { subtype: msg.subtype } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`);
|
||||||
/* channel push is best-effort; check_messages is the fallback */
|
} 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 */ }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Welcome notification: give Claude immediate context on connect.
|
||||||
|
// Triggers Claude to call mesh_info/list_peers without user input.
|
||||||
|
setTimeout(async () => {
|
||||||
|
const client = allClients()[0];
|
||||||
|
if (!client || client.status !== "open") return;
|
||||||
|
try {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
const peerNames = peers
|
||||||
|
.filter(p => p.displayName !== myName)
|
||||||
|
.map(p => p.displayName)
|
||||||
|
.join(", ") || "none";
|
||||||
|
await server.notification({
|
||||||
|
method: "notifications/claude/channel",
|
||||||
|
params: {
|
||||||
|
content: `[system] Connected as ${myName} to mesh ${client.meshSlug}. ${peers.length} peer(s) online: ${peerNames}. Call mesh_info for full details or set_summary to announce yourself.`,
|
||||||
|
meta: { kind: "welcome", mesh_slug: client.meshSlug },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}, 3_000); // 3s delay: let WS connect + hello_ack complete first
|
||||||
|
|
||||||
|
// Event loop keepalive: Node.js stdout to a pipe is buffered. Without
|
||||||
|
// periodic event loop activity, stdout.write() from WS callbacks may not
|
||||||
|
// flush until the next I/O event. This 1s interval keeps the event loop
|
||||||
|
// ticking so channel notifications flush promptly — same pattern that made
|
||||||
|
// claude-intercom's push delivery reliable (its 1s HTTP poll had this
|
||||||
|
// effect as a side effect). The interval does nothing except prevent the
|
||||||
|
// event loop from settling.
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
// Intentionally empty — the interval itself keeps the event loop active.
|
||||||
|
// Do NOT call .unref() — that would defeat the purpose.
|
||||||
|
}, 1_000);
|
||||||
|
void keepalive; // suppress unused warning
|
||||||
|
|
||||||
const shutdown = (): void => {
|
const shutdown = (): void => {
|
||||||
|
clearInterval(keepalive);
|
||||||
stopAll();
|
stopAll();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
name: "send_message",
|
name: "send_message",
|
||||||
description:
|
description:
|
||||||
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
to: {
|
to: {
|
||||||
type: "string",
|
oneOf: [
|
||||||
description: "Peer name, pubkey, or #channel",
|
{ 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" },
|
message: { type: "string", description: "Message text" },
|
||||||
priority: {
|
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",
|
name: "check_messages",
|
||||||
description:
|
description:
|
||||||
@@ -78,4 +96,532 @@ export const TOOLS: Tool[] = [
|
|||||||
required: ["status"],
|
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. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Local file path to share" },
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Display name (defaults to filename)",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for categorization",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_file",
|
||||||
|
description: "Download a shared file to a local path.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "File ID" },
|
||||||
|
save_to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Local path to save the file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id", "save_to"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_files",
|
||||||
|
description: "List files shared in the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Search by name or tags" },
|
||||||
|
from: { type: "string", description: "Filter by uploader name" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file_status",
|
||||||
|
description: "Check who has accessed a shared file.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "File ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_file",
|
||||||
|
description: "Remove a shared file from the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "File ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grant_file_access",
|
||||||
|
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fileId: { type: "string", description: "File ID" },
|
||||||
|
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
|
||||||
|
},
|
||||||
|
required: ["fileId", "to"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Vector tools ---
|
||||||
|
{
|
||||||
|
name: "vector_store",
|
||||||
|
description:
|
||||||
|
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collection: { type: "string", description: "Collection name" },
|
||||||
|
text: { type: "string", description: "Text to embed and store" },
|
||||||
|
metadata: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional metadata to attach",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["collection", "text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vector_search",
|
||||||
|
description: "Semantic search over stored embeddings in a collection.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collection: { type: "string", description: "Collection name" },
|
||||||
|
query: { type: "string", description: "Search query text" },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Max results (default: 10)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["collection", "query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vector_delete",
|
||||||
|
description: "Remove an embedding from a collection.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collection: { type: "string", description: "Collection name" },
|
||||||
|
id: { type: "string", description: "Embedding ID to delete" },
|
||||||
|
},
|
||||||
|
required: ["collection", "id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_collections",
|
||||||
|
description: "List vector collections in this mesh.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Graph tools ---
|
||||||
|
{
|
||||||
|
name: "graph_query",
|
||||||
|
description:
|
||||||
|
"Run a read-only Cypher query on the per-mesh Neo4j database.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
cypher: { type: "string", description: "Cypher MATCH query" },
|
||||||
|
},
|
||||||
|
required: ["cypher"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "graph_execute",
|
||||||
|
description:
|
||||||
|
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
cypher: { type: "string", description: "Cypher write query" },
|
||||||
|
},
|
||||||
|
required: ["cypher"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Mesh Database tools ---
|
||||||
|
{
|
||||||
|
name: "mesh_query",
|
||||||
|
description:
|
||||||
|
"Run a SELECT query on the per-mesh shared database.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sql: { type: "string", description: "SQL SELECT query" },
|
||||||
|
},
|
||||||
|
required: ["sql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_execute",
|
||||||
|
description:
|
||||||
|
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sql: { type: "string", description: "SQL statement" },
|
||||||
|
},
|
||||||
|
required: ["sql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_schema",
|
||||||
|
description:
|
||||||
|
"List tables and columns in the per-mesh shared database.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Stream tools ---
|
||||||
|
{
|
||||||
|
name: "create_stream",
|
||||||
|
description:
|
||||||
|
"Create a real-time data stream in the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Stream name" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publish",
|
||||||
|
description:
|
||||||
|
"Push data to a stream. Subscribers receive it in real-time.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
stream: { type: "string", description: "Stream name" },
|
||||||
|
data: { description: "Any JSON data to publish" },
|
||||||
|
},
|
||||||
|
required: ["stream", "data"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subscribe",
|
||||||
|
description:
|
||||||
|
"Subscribe to a stream. Data pushes arrive as channel notifications.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
stream: { type: "string", description: "Stream name" },
|
||||||
|
},
|
||||||
|
required: ["stream"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_streams",
|
||||||
|
description:
|
||||||
|
"List active streams in the mesh.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Context tools ---
|
||||||
|
{
|
||||||
|
name: "share_context",
|
||||||
|
description:
|
||||||
|
"Share your session understanding with the mesh. Call after exploring a codebase area.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: {
|
||||||
|
type: "string",
|
||||||
|
description: "Summary of what you explored/learned",
|
||||||
|
},
|
||||||
|
files_read: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "File paths you read",
|
||||||
|
},
|
||||||
|
key_findings: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Key findings or insights",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for categorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_context",
|
||||||
|
description:
|
||||||
|
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Search query (file path, topic, etc.)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_contexts",
|
||||||
|
description: "See what all peers currently know about the codebase.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Task tools ---
|
||||||
|
{
|
||||||
|
name: "create_task",
|
||||||
|
description: "Create a work item for the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string", description: "Task title" },
|
||||||
|
assignee: {
|
||||||
|
type: "string",
|
||||||
|
description: "Peer name to assign (optional)",
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["low", "normal", "high", "urgent"],
|
||||||
|
description: "Priority level (default: normal)",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for categorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claim_task",
|
||||||
|
description: "Claim an unclaimed task to take ownership.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Task ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete_task",
|
||||||
|
description: "Mark a task as done with an optional result summary.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Task ID" },
|
||||||
|
result: {
|
||||||
|
type: "string",
|
||||||
|
description: "Summary of what was done",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_tasks",
|
||||||
|
description: "List tasks filtered by status and/or assignee.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["open", "claimed", "completed"],
|
||||||
|
description: "Filter by status",
|
||||||
|
},
|
||||||
|
assignee: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter by assignee name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Scheduled messages ---
|
||||||
|
{
|
||||||
|
name: "schedule_reminder",
|
||||||
|
description:
|
||||||
|
"Schedule a message for future delivery. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. The broker holds it and delivers when the time arrives. Receivers see `subtype: reminder` in the push envelope.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
message: { type: "string", description: "Message or reminder text" },
|
||||||
|
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" },
|
||||||
|
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" },
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["message"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_scheduled",
|
||||||
|
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel_scheduled",
|
||||||
|
description: "Cancel a pending scheduled message before it fires.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Scheduled message ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Mesh info ---
|
||||||
|
{
|
||||||
|
name: "mesh_info",
|
||||||
|
description:
|
||||||
|
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Diagnostics ---
|
||||||
|
{
|
||||||
|
name: "ping_mesh",
|
||||||
|
description:
|
||||||
|
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
priorities: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", enum: ["now", "next", "low"] },
|
||||||
|
description: "Priorities to test (default: [\"now\", \"next\"])",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
|
|||||||
export type PeerStatus = "idle" | "working" | "dnd";
|
export type PeerStatus = "idle" | "working" | "dnd";
|
||||||
|
|
||||||
export interface SendMessageArgs {
|
export interface SendMessageArgs {
|
||||||
to: string; // peer name, pubkey, or #channel
|
to: string | string[]; // peer name, pubkey, @group, or array of targets
|
||||||
message: string;
|
message: string;
|
||||||
priority?: Priority;
|
priority?: Priority;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,18 @@ export interface JoinedMesh {
|
|||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupEntry {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
version: 1;
|
version: 1;
|
||||||
meshes: JoinedMesh[];
|
meshes: JoinedMesh[];
|
||||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||||
|
role?: string; // per-session role tag (display + hello)
|
||||||
|
groups?: GroupEntry[];
|
||||||
|
messageMode?: "push" | "inbox" | "off";
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
@@ -47,7 +55,7 @@ export function loadConfig(): Config {
|
|||||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||||
return { version: 1, meshes: [] };
|
return { version: 1, meshes: [] };
|
||||||
}
|
}
|
||||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName };
|
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface PeerInfo {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
status: string;
|
status: string;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
connectedAt: string;
|
connectedAt: string;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,8 @@ export interface InboundPush {
|
|||||||
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
|
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
|
||||||
* (plaintext for now). */
|
* (plaintext for now). */
|
||||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||||
|
/** Optional semantic tag — "reminder" when fired by the scheduler. */
|
||||||
|
subtype?: "reminder";
|
||||||
}
|
}
|
||||||
|
|
||||||
type PushHandler = (msg: InboundPush) => void;
|
type PushHandler = (msg: InboundPush) => void;
|
||||||
@@ -74,9 +77,15 @@ export class BrokerClient {
|
|||||||
private outbound: Array<() => void> = []; // closures that send once ws is open
|
private outbound: Array<() => void> = []; // closures that send once ws is open
|
||||||
private pushHandlers = new Set<PushHandler>();
|
private pushHandlers = new Set<PushHandler>();
|
||||||
private pushBuffer: InboundPush[] = [];
|
private pushBuffer: InboundPush[] = [];
|
||||||
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
|
private listPeersResolvers = new Map<string, { resolve: (peers: PeerInfo[]) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private stateResolvers = new Map<string, { resolve: (result: { key: string; value: unknown; updatedBy: string; updatedAt: string } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private stateListResolvers = new Map<string, { resolve: (entries: Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private memoryStoreResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private memoryRecallResolvers = new Map<string, { resolve: (memories: Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
|
||||||
private sessionPubkey: string | null = null;
|
private sessionPubkey: string | null = null;
|
||||||
private sessionSecretKey: string | null = null;
|
private sessionSecretKey: string | null = null;
|
||||||
|
private grantFileAccessResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private reconnectAttempt = 0;
|
private reconnectAttempt = 0;
|
||||||
private helloTimer: NodeJS.Timeout | null = null;
|
private helloTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -104,6 +113,15 @@ export class BrokerClient {
|
|||||||
return this.pushBuffer;
|
return this.pushBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Session public key hex (null before first connection). */
|
||||||
|
getSessionPubkey(): string | null { return this.sessionPubkey; }
|
||||||
|
/** Session secret key hex (null before first connection). */
|
||||||
|
getSessionSecretKey(): string | null { return this.sessionSecretKey; }
|
||||||
|
|
||||||
|
private makeReqId(): string {
|
||||||
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
/** Open WS, send hello, resolve when hello_ack received. */
|
/** Open WS, send hello, resolve when hello_ack received. */
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.closed) throw new Error("client is closed");
|
if (this.closed) throw new Error("client is closed");
|
||||||
@@ -293,16 +311,11 @@ export class BrokerClient {
|
|||||||
async listPeers(): Promise<PeerInfo[]> {
|
async listPeers(): Promise<PeerInfo[]> {
|
||||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.listPeersResolvers.push(resolve);
|
const reqId = this.makeReqId();
|
||||||
this.ws!.send(JSON.stringify({ type: "list_peers" }));
|
this.listPeersResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
// Timeout after 5s — return empty list rather than hang.
|
if (this.listPeersResolvers.delete(reqId)) resolve([]);
|
||||||
setTimeout(() => {
|
}, 5_000) });
|
||||||
const idx = this.listPeersResolvers.indexOf(resolve);
|
this.ws!.send(JSON.stringify({ type: "list_peers", _reqId: reqId }));
|
||||||
if (idx !== -1) {
|
|
||||||
this.listPeersResolvers.splice(idx, 1);
|
|
||||||
resolve([]);
|
|
||||||
}
|
|
||||||
}, 5_000);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +325,503 @@ export class BrokerClient {
|
|||||||
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.stateResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.stateResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.stateListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.stateListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_state", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.memoryStoreResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.memoryStoreResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "remember", content, tags, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.memoryRecallResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.memoryRecallResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "recall", query, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scheduled messages ---
|
||||||
|
|
||||||
|
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt } or null on timeout. */
|
||||||
|
async scheduleMessage(
|
||||||
|
to: string,
|
||||||
|
message: string,
|
||||||
|
deliverAt: number,
|
||||||
|
isReminder = false,
|
||||||
|
): Promise<{ scheduledId: string; deliverAt: number } | null> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.scheduledAckResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.scheduledAckResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 8_000) });
|
||||||
|
this.ws!.send(JSON.stringify({
|
||||||
|
type: "schedule",
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
deliverAt,
|
||||||
|
...(isReminder ? { subtype: "reminder" } : {}),
|
||||||
|
_reqId: reqId,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all pending scheduled messages for this session. */
|
||||||
|
async listScheduled(): Promise<Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.scheduledListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.scheduledListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_scheduled", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a scheduled message by id. Returns true if found and cancelled. */
|
||||||
|
async cancelScheduled(scheduledId: string): Promise<boolean> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.cancelScheduledResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.cancelScheduledResolvers.delete(reqId)) resolve(false);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "cancel_scheduled", scheduledId, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check delivery status of a sent message. */
|
||||||
|
private messageStatusResolvers = new Map<string, { resolve: (result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private fileUrlResolvers = new Map<string, { resolve: (result: { url: string; name: string; encrypted?: boolean; sealedKey?: string } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private fileListResolvers = new Map<string, { resolve: (files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private fileStatusResolvers = new Map<string, { resolve: (accesses: Array<{ peerName: string; accessedAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private vectorStoredResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private vectorResultsResolvers = new Map<string, { resolve: (results: Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private collectionListResolvers = new Map<string, { resolve: (collections: string[]) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private graphResultResolvers = new Map<string, { resolve: (rows: Array<Record<string, unknown>>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private contextListResolvers = new Map<string, { resolve: (contexts: Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private contextResultsResolvers = new Map<string, { resolve: (contexts: Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private taskCreatedResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private taskListResolvers = new Map<string, { resolve: (tasks: Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private meshQueryResolvers = new Map<string, { resolve: (result: { columns: string[]; rows: Array<Record<string, unknown>>; rowCount: number } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private meshSchemaResolvers = new Map<string, { resolve: (tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private streamCreatedResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private streamListResolvers = new Map<string, { resolve: (streams: Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private streamDataHandlers = new Set<(data: { stream: string; data: unknown; publishedBy: string }) => void>();
|
||||||
|
private scheduledAckResolvers = new Map<string, { resolve: (result: { scheduledId: string; deliverAt: number } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private scheduledListResolvers = new Map<string, { resolve: (messages: Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private cancelScheduledResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
|
||||||
|
|
||||||
|
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.messageStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.messageStatusResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "message_status", messageId, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Files ---
|
||||||
|
|
||||||
|
/** Get a download URL for a shared file. */
|
||||||
|
async getFile(fileId: string): Promise<{ url: string; name: string; encrypted?: boolean; sealedKey?: string } | null> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.fileUrlResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.fileUrlResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "get_file", fileId, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.fileListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.fileListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_files", query, from, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.fileStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.fileStatusResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "file_status", fileId, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
|
||||||
|
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
|
||||||
|
encrypted?: boolean; ownerPubkey?: string; fileKeys?: Array<{ peerPubkey: string; sealedKey: string }>;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { readFileSync } = await import("node:fs");
|
||||||
|
const { basename } = await import("node:path");
|
||||||
|
const data = readFileSync(filePath);
|
||||||
|
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 ?? "",
|
||||||
|
...(opts.encrypted ? { "X-Encrypted": "true" } : {}),
|
||||||
|
...(opts.ownerPubkey ? { "X-Owner-Pubkey": opts.ownerPubkey } : {}),
|
||||||
|
...(opts.fileKeys?.length ? { "X-File-Keys": JSON.stringify(opts.fileKeys) } : {}),
|
||||||
|
},
|
||||||
|
body: data,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
|
||||||
|
if (!res.ok || !body.fileId) {
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return body.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grant a peer access to an encrypted file (owner only). */
|
||||||
|
async grantFileAccess(fileId: string, peerPubkey: string, sealedKey: string): Promise<boolean> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.grantFileAccessResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.grantFileAccessResolvers.delete(reqId)) resolve(false);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "grant_file_access", fileId, peerPubkey, sealedKey, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Vectors ---
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.vectorStoredResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.vectorStoredResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "vector_store", collection, text, metadata, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.vectorResultsResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.vectorResultsResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "vector_search", collection, query, limit, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.collectionListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.collectionListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_collections", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.graphResultResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.graphResultResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "graph_query", cypher, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.graphResultResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.graphResultResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "graph_execute", cypher, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.contextResultsResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.contextResultsResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "get_context", query, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.contextListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.contextListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_contexts", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.taskCreatedResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.taskCreatedResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "create_task", title, assignee, priority, tags, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claim an unclaimed task. */
|
||||||
|
async claimTask(id: string): Promise<void> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify({ type: "claim_task", taskId: id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a task done with optional result. */
|
||||||
|
async completeTask(id: string, result?: string): Promise<void> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify({ type: "complete_task", taskId: id, result }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List tasks filtered by status/assignee. */
|
||||||
|
async listTasks(status?: string, assignee?: string): Promise<Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.taskListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.taskListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_tasks", status, assignee, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.meshQueryResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.meshQueryResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "mesh_query", sql, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.meshSchemaResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.meshSchemaResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "mesh_schema", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.streamCreatedResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.streamCreatedResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "create_stream", name, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.streamListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.streamListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_streams", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 = new Map<string, { resolve: (result: Record<string, unknown> | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
|
||||||
|
async meshInfo(): Promise<Record<string, unknown> | null> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.meshInfoResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||||
|
if (this.meshInfoResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000) });
|
||||||
|
this.ws!.send(JSON.stringify({ type: "mesh_info", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||||
@@ -328,7 +838,33 @@ export class BrokerClient {
|
|||||||
|
|
||||||
// --- Internals ---
|
// --- Internals ---
|
||||||
|
|
||||||
|
private resolveFromMap<T>(
|
||||||
|
map: Map<string, { resolve: (v: T) => void; timer: NodeJS.Timeout }>,
|
||||||
|
reqId: string | undefined,
|
||||||
|
value: T,
|
||||||
|
): boolean {
|
||||||
|
let entry = reqId ? map.get(reqId) : undefined;
|
||||||
|
if (!entry) {
|
||||||
|
// Fallback: oldest pending (FIFO, for brokers that don't echo _reqId)
|
||||||
|
const first = map.entries().next().value as [string, { resolve: (v: T) => void; timer: NodeJS.Timeout }] | undefined;
|
||||||
|
if (first) {
|
||||||
|
entry = first[1];
|
||||||
|
map.delete(first[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
map.delete(reqId!);
|
||||||
|
}
|
||||||
|
if (entry) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.resolve(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||||
|
const msgReqId = msg._reqId as string | undefined;
|
||||||
|
|
||||||
if (msg.type === "ack") {
|
if (msg.type === "ack") {
|
||||||
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -342,8 +878,7 @@ export class BrokerClient {
|
|||||||
}
|
}
|
||||||
if (msg.type === "peers_list") {
|
if (msg.type === "peers_list") {
|
||||||
const peers = (msg.peers as PeerInfo[]) ?? [];
|
const peers = (msg.peers as PeerInfo[]) ?? [];
|
||||||
const resolver = this.listPeersResolvers.shift();
|
this.resolveFromMap(this.listPeersResolvers, msgReqId, peers);
|
||||||
if (resolver) resolver(peers);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "push") {
|
if (msg.type === "push") {
|
||||||
@@ -402,6 +937,7 @@ export class BrokerClient {
|
|||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
plaintext,
|
plaintext,
|
||||||
kind,
|
kind,
|
||||||
|
...(msg.subtype ? { subtype: msg.subtype as "reminder" } : {}),
|
||||||
};
|
};
|
||||||
this.pushBuffer.push(push);
|
this.pushBuffer.push(push);
|
||||||
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
|
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
|
||||||
@@ -415,9 +951,180 @@ export class BrokerClient {
|
|||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === "state_result") {
|
||||||
|
// DEPENDENCY: The broker must NOT send state_result for set_state
|
||||||
|
// operations (only for get_state). If the broker sends state_result for
|
||||||
|
// both, it would be consumed here by the next pending get_state resolver,
|
||||||
|
// returning the wrong value (cross-contamination). The broker's set_state
|
||||||
|
// handler was fixed to omit state_result; only get_state sends it.
|
||||||
|
if (msg.key) {
|
||||||
|
this.resolveFromMap(this.stateResolvers, msgReqId, {
|
||||||
|
key: String(msg.key),
|
||||||
|
value: msg.value,
|
||||||
|
updatedBy: String(msg.updatedBy ?? ""),
|
||||||
|
updatedAt: String(msg.updatedAt ?? ""),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.resolveFromMap(this.stateResolvers, msgReqId, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "state_list") {
|
||||||
|
const entries = (msg.entries as Array<{ key: string; value: unknown; updatedBy: string; updatedAt: string }>) ?? [];
|
||||||
|
this.resolveFromMap(this.stateListResolvers, msgReqId, 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") {
|
||||||
|
this.resolveFromMap(this.memoryStoreResolvers, msgReqId, msg.id ? String(msg.id) : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "memory_results") {
|
||||||
|
const memories = (msg.memories as Array<{ id: string; content: string; tags: string[]; rememberedBy: string; rememberedAt: string }>) ?? [];
|
||||||
|
this.resolveFromMap(this.memoryRecallResolvers, msgReqId, memories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "message_status_result") {
|
||||||
|
this.resolveFromMap(this.messageStatusResolvers, msgReqId, msg as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "file_url") {
|
||||||
|
if (msg.url) {
|
||||||
|
this.resolveFromMap(this.fileUrlResolvers, msgReqId, {
|
||||||
|
url: String(msg.url),
|
||||||
|
name: String(msg.name ?? ""),
|
||||||
|
encrypted: msg.encrypted ? true : undefined,
|
||||||
|
sealedKey: msg.sealedKey ? String(msg.sealedKey) : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.resolveFromMap(this.fileUrlResolvers, msgReqId, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "file_list") {
|
||||||
|
const files = (msg.files as Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) ?? [];
|
||||||
|
this.resolveFromMap(this.fileListResolvers, msgReqId, files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "file_status_result") {
|
||||||
|
const accesses = (msg.accesses as Array<{ peerName: string; accessedAt: string }>) ?? [];
|
||||||
|
this.resolveFromMap(this.fileStatusResolvers, msgReqId, accesses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "grant_file_access_ok") {
|
||||||
|
this.resolveFromMap(this.grantFileAccessResolvers, msgReqId, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "vector_stored") {
|
||||||
|
this.resolveFromMap(this.vectorStoredResolvers, msgReqId, msg.id ? String(msg.id) : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "vector_results") {
|
||||||
|
const results = (msg.results as Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) ?? [];
|
||||||
|
this.resolveFromMap(this.vectorResultsResolvers, msgReqId, results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "collection_list") {
|
||||||
|
const collections = (msg.collections as string[]) ?? [];
|
||||||
|
this.resolveFromMap(this.collectionListResolvers, msgReqId, collections);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "graph_result") {
|
||||||
|
// Broker sends { type: "graph_result", records: [...] }
|
||||||
|
const rows = (msg.records as Array<Record<string, unknown>>) ?? [];
|
||||||
|
this.resolveFromMap(this.graphResultResolvers, msgReqId, rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "context_list") {
|
||||||
|
const contexts = (msg.contexts as Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) ?? [];
|
||||||
|
this.resolveFromMap(this.contextListResolvers, msgReqId, contexts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "context_results") {
|
||||||
|
const contexts = (msg.contexts as Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) ?? [];
|
||||||
|
this.resolveFromMap(this.contextResultsResolvers, msgReqId, contexts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "task_created") {
|
||||||
|
this.resolveFromMap(this.taskCreatedResolvers, msgReqId, msg.id ? String(msg.id) : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "task_list") {
|
||||||
|
const tasks = (msg.tasks as Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) ?? [];
|
||||||
|
this.resolveFromMap(this.taskListResolvers, msgReqId, tasks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "mesh_query_result") {
|
||||||
|
if (msg.columns) {
|
||||||
|
this.resolveFromMap(this.meshQueryResolvers, msgReqId, {
|
||||||
|
columns: (msg.columns as string[]) ?? [],
|
||||||
|
rows: (msg.rows as Array<Record<string, unknown>>) ?? [],
|
||||||
|
rowCount: (msg.rowCount as number) ?? 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.resolveFromMap(this.meshQueryResolvers, msgReqId, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "mesh_schema_result") {
|
||||||
|
const tables = (msg.tables as Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) ?? [];
|
||||||
|
this.resolveFromMap(this.meshSchemaResolvers, msgReqId, tables);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "stream_created") {
|
||||||
|
this.resolveFromMap(this.streamCreatedResolvers, msgReqId, msg.id ? String(msg.id) : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "stream_list") {
|
||||||
|
const streams = (msg.streams as Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) ?? [];
|
||||||
|
this.resolveFromMap(this.streamListResolvers, msgReqId, 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") {
|
||||||
|
this.resolveFromMap(this.meshInfoResolvers, msgReqId, msg as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "scheduled_ack") {
|
||||||
|
this.resolveFromMap(this.scheduledAckResolvers, msgReqId, {
|
||||||
|
scheduledId: String(msg.scheduledId ?? ""),
|
||||||
|
deliverAt: Number(msg.deliverAt ?? 0),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "scheduled_list") {
|
||||||
|
const messages = (msg.messages as Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) ?? [];
|
||||||
|
this.resolveFromMap(this.scheduledListResolvers, msgReqId, messages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "cancel_scheduled_ack") {
|
||||||
|
this.resolveFromMap(this.cancelScheduledResolvers, msgReqId, Boolean(msg.ok));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === "error") {
|
if (msg.type === "error") {
|
||||||
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
||||||
const id = msg.id ? String(msg.id) : null;
|
const id = msg.id ? String(msg.id) : null;
|
||||||
|
let handledByPendingSend = false;
|
||||||
if (id) {
|
if (id) {
|
||||||
const pending = this.pendingSends.get(id);
|
const pending = this.pendingSends.get(id);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -426,6 +1133,49 @@ export class BrokerClient {
|
|||||||
error: `${msg.code}: ${msg.message}`,
|
error: `${msg.code}: ${msg.message}`,
|
||||||
});
|
});
|
||||||
this.pendingSends.delete(id);
|
this.pendingSends.delete(id);
|
||||||
|
handledByPendingSend = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!handledByPendingSend) {
|
||||||
|
// Best-effort: unblock the first waiting resolver so callers don't
|
||||||
|
// hang for 5s. We don't know which tool triggered the error, so we
|
||||||
|
// pop the first non-empty resolver map in priority order.
|
||||||
|
const allMaps: Array<[Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>, unknown]> = [
|
||||||
|
[this.stateResolvers, null],
|
||||||
|
[this.stateListResolvers, []],
|
||||||
|
[this.memoryStoreResolvers, null],
|
||||||
|
[this.memoryRecallResolvers, []],
|
||||||
|
[this.fileUrlResolvers, null],
|
||||||
|
[this.fileListResolvers, []],
|
||||||
|
[this.fileStatusResolvers, []],
|
||||||
|
[this.graphResultResolvers, []],
|
||||||
|
[this.vectorStoredResolvers, null],
|
||||||
|
[this.vectorResultsResolvers, []],
|
||||||
|
[this.taskListResolvers, []],
|
||||||
|
[this.meshQueryResolvers, null],
|
||||||
|
[this.contextResultsResolvers, []],
|
||||||
|
[this.contextListResolvers, []],
|
||||||
|
[this.streamListResolvers, []],
|
||||||
|
[this.scheduledAckResolvers, null],
|
||||||
|
[this.scheduledListResolvers, []],
|
||||||
|
[this.cancelScheduledResolvers, false],
|
||||||
|
[this.messageStatusResolvers, null],
|
||||||
|
[this.grantFileAccessResolvers, false],
|
||||||
|
[this.collectionListResolvers, []],
|
||||||
|
[this.meshSchemaResolvers, []],
|
||||||
|
[this.taskCreatedResolvers, null],
|
||||||
|
[this.streamCreatedResolvers, null],
|
||||||
|
[this.listPeersResolvers, []],
|
||||||
|
[this.meshInfoResolvers, null],
|
||||||
|
];
|
||||||
|
for (const [map, defaultVal] of allMaps) {
|
||||||
|
const first = (map as Map<string, any>).entries().next().value as [string, { resolve: (v: unknown) => void; timer: NodeJS.Timeout }] | undefined;
|
||||||
|
if (first) {
|
||||||
|
(map as Map<string, any>).delete(first[0]);
|
||||||
|
clearTimeout(first[1].timer);
|
||||||
|
first[1].resolve(defaultVal);
|
||||||
|
break; // only pop one
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { env } from "../env";
|
|||||||
|
|
||||||
const clients = new Map<string, BrokerClient>();
|
const clients = new Map<string, BrokerClient>();
|
||||||
let configDisplayName: string | undefined;
|
let configDisplayName: string | undefined;
|
||||||
|
let configGroups: Config["groups"] = [];
|
||||||
|
|
||||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||||
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
clients.set(mesh.meshId, client);
|
clients.set(mesh.meshId, client);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
// Auto-join groups declared at launch time (--groups flag or config).
|
||||||
|
for (const g of configGroups ?? []) {
|
||||||
|
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Connect failed → client is in "reconnecting" state, leave it
|
// Connect failed → client is in "reconnecting" state, leave it
|
||||||
// wired so tool calls can surface the status.
|
// wired so tool calls can surface the status.
|
||||||
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||||
export async function startClients(config: Config): Promise<void> {
|
export async function startClients(config: Config): Promise<void> {
|
||||||
configDisplayName = config.displayName;
|
configDisplayName = config.displayName;
|
||||||
|
configGroups = config.groups ?? [];
|
||||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
|||||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
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...
|
RUN npx turbo run build --filter=web...
|
||||||
|
|
||||||
# Stage 2: runtime — standalone output only
|
# Stage 2: runtime — standalone output only
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
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";
|
import env from "./env.config";
|
||||||
|
|
||||||
const INTERNAL_PACKAGES = [
|
const INTERNAL_PACKAGES = [
|
||||||
@@ -130,4 +133,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
|||||||
enabled: env.ANALYZE,
|
enabled: env.ANALYZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBundleAnalyzer(config);
|
export default withPayload(withBundleAnalyzer(config));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build --no-turbopack",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
|||||||
14
apps/web/src/app/(payload)/layout.tsx
Normal file
14
apps/web/src/app/(payload)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
16
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
51
apps/web/src/app/(payload)/payload/importMap.js
Normal 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
|
||||||
|
}
|
||||||
548
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
548
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { getMetadata } from "~/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata = getMetadata({
|
||||||
|
title: "Getting Started",
|
||||||
|
description:
|
||||||
|
"Install claudemesh, join a mesh, and launch your first peer session in under two minutes.",
|
||||||
|
})();
|
||||||
|
|
||||||
|
const STEP = ({
|
||||||
|
n,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
cmd,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
n: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
cmd?: string;
|
||||||
|
note?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 md:p-8">
|
||||||
|
<div
|
||||||
|
className="mb-4 flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)]/15 text-[11px] font-medium">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{cmd && (
|
||||||
|
<pre
|
||||||
|
className="mt-4 overflow-x-auto rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-3 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{cmd}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{note && (
|
||||||
|
<p
|
||||||
|
className="mt-3 text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const VERIFY_CHECKS = [
|
||||||
|
"Node.js >= 20 installed",
|
||||||
|
"claude binary on PATH",
|
||||||
|
"claudemesh MCP registered in ~/.claude.json",
|
||||||
|
"Status hooks registered in ~/.claude/settings.json",
|
||||||
|
"~/.claudemesh/config.json parses + chmod 0600",
|
||||||
|
"Mesh keypairs valid",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function GettingStartedPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-16 md:px-12 md:py-24">
|
||||||
|
<div
|
||||||
|
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— getting started
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
From zero to meshed in two minutes
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-4 max-w-xl text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Install the CLI, join a mesh, and launch Claude Code with real-time peer
|
||||||
|
messaging. Three commands.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Prerequisites */}
|
||||||
|
<div className="mt-14 mb-10">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Prerequisites
|
||||||
|
</h2>
|
||||||
|
<ul
|
||||||
|
className="space-y-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-[var(--cm-fg)]">Node.js 20+</strong> —{" "}
|
||||||
|
<Link
|
||||||
|
href="https://nodejs.org"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
nodejs.org
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-[var(--cm-fg)]">Claude Code 2.0+</strong>{" "}
|
||||||
|
—{" "}
|
||||||
|
<Link
|
||||||
|
href="https://claude.com/claude-code"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
claude.com/claude-code
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-[var(--cm-fg)]">An invite link</strong> —
|
||||||
|
from a mesh owner, or{" "}
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
create your own mesh
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<STEP
|
||||||
|
n="1"
|
||||||
|
title="Install the CLI"
|
||||||
|
cmd="curl -fsSL https://claudemesh.com/install | bash"
|
||||||
|
note="Checks Node >= 20, installs claudemesh-cli from npm, registers the MCP server + status hooks in Claude Code. Equivalent to: npm install -g claudemesh-cli && claudemesh install"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
One command installs the CLI globally and configures Claude Code.
|
||||||
|
The script is short and auditable —{" "}
|
||||||
|
<Link
|
||||||
|
href="https://claudemesh.com/install"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
read it first
|
||||||
|
</Link>{" "}
|
||||||
|
if you prefer.
|
||||||
|
</p>
|
||||||
|
</STEP>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
or install manually:
|
||||||
|
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
|
||||||
|
npm install -g claudemesh-cli && claudemesh install
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<STEP
|
||||||
|
n="2"
|
||||||
|
title="Restart Claude Code"
|
||||||
|
note="The MCP server and status hooks registered in step 1 only take effect after a restart."
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Close and reopen Claude Code (or your IDE with Claude Code
|
||||||
|
extension). This loads the claudemesh MCP server so the 43 mesh
|
||||||
|
tools appear.
|
||||||
|
</p>
|
||||||
|
</STEP>
|
||||||
|
|
||||||
|
<STEP
|
||||||
|
n="3"
|
||||||
|
title="Join a mesh"
|
||||||
|
cmd="claudemesh join https://claudemesh.com/join/eyJ2IjoxLC..."
|
||||||
|
note="Replace the URL with your actual invite link. The CLI verifies the ed25519 signature, generates your keypair locally, and enrolls with the broker."
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Paste the invite link you received. Your ed25519 keypair is
|
||||||
|
generated and stored in{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
~/.claudemesh/config.json
|
||||||
|
</code>{" "}
|
||||||
|
(chmod 0600). You keep your keys — the broker never sees them.
|
||||||
|
</p>
|
||||||
|
</STEP>
|
||||||
|
|
||||||
|
<STEP
|
||||||
|
n="4"
|
||||||
|
title="Launch with real-time messaging"
|
||||||
|
cmd="claudemesh launch --name Alice"
|
||||||
|
note="Wraps `claude` with the mesh dev-channel. Peers can message you in real-time. Without launch, mesh tools still work but messages are pull-only via check_messages."
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This spawns Claude Code connected to the mesh with push messaging.
|
||||||
|
The interactive wizard asks for your role and groups — or pass them
|
||||||
|
as flags:
|
||||||
|
</p>
|
||||||
|
</STEP>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`# Full example with all flags
|
||||||
|
claudemesh launch \\
|
||||||
|
--name Alice \\
|
||||||
|
--role dev \\
|
||||||
|
--groups "frontend:lead,reviewers" \\
|
||||||
|
--message-mode push \\
|
||||||
|
-y # skip permission confirmation`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verify */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Verify your setup
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Run the diagnostic check — it walks through every precondition and
|
||||||
|
prints pass/fail with fix hints:
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`$ claudemesh doctor
|
||||||
|
claudemesh doctor (v0.6.8)
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
✓ Node.js >= 20 (v22.15.0)
|
||||||
|
✓ claude binary on PATH
|
||||||
|
✓ claudemesh MCP registered in ~/.claude.json
|
||||||
|
✓ Status hooks registered in ~/.claude/settings.json
|
||||||
|
✓ ~/.claudemesh/config.json parses + chmod 0600
|
||||||
|
✓ Mesh keypairs valid (1 mesh(es))
|
||||||
|
|
||||||
|
All checks passed.`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What install does */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
What <code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh install</code> does
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
The install command touches two files. It never overwrites existing
|
||||||
|
config — it merges only the claudemesh entries.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
~/.claude.json
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Registers{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
mcpServers.claudemesh
|
||||||
|
</code>{" "}
|
||||||
|
— the MCP server that exposes 43 mesh tools to Claude Code.
|
||||||
|
Backed up before every write.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
~/.claude/settings.json
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Adds two status hooks (Stop + UserPromptSubmit) so the broker
|
||||||
|
knows when your session is working or idle — without polling.
|
||||||
|
Pre-approves all 43 claudemesh tools in{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
allowedTools
|
||||||
|
</code>{" "}
|
||||||
|
so they run without --dangerously-skip-permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite a teammate */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Invite a teammate
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Mesh owners generate invite links from the{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
dashboard
|
||||||
|
</Link>
|
||||||
|
. Each link is a signed ed25519 token with a mesh ID, broker URL,
|
||||||
|
expiry, and role (admin or member). Share via Slack, email, or
|
||||||
|
paste in chat.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
The recipient runs{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh join <link>
|
||||||
|
</code>{" "}
|
||||||
|
— the CLI verifies the signature client-side before enrolling with
|
||||||
|
the broker. No account creation needed. Identity is the ed25519
|
||||||
|
keypair.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite link formats */}
|
||||||
|
<div className="mt-10">
|
||||||
|
<h3
|
||||||
|
className="mb-3 text-base font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Accepted invite formats
|
||||||
|
</h3>
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`# HTTPS link (clickable, shareable)
|
||||||
|
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||||
|
|
||||||
|
# With locale prefix (also works)
|
||||||
|
claudemesh join https://claudemesh.com/en/join/eyJ2IjoxLC...
|
||||||
|
|
||||||
|
# ic:// scheme (legacy, still supported)
|
||||||
|
claudemesh join ic://join/eyJ2IjoxLC...
|
||||||
|
|
||||||
|
# Raw token (last resort)
|
||||||
|
claudemesh join eyJ2IjoxLC4uLg`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message modes */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Message modes
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
mode: "push",
|
||||||
|
desc: "Real-time. Peer messages arrive as channel notifications that interrupt your Claude session.",
|
||||||
|
when: "Default. Best for active collaboration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "inbox",
|
||||||
|
desc: "Held until you check. You get a notification but messages queue until check_messages.",
|
||||||
|
when: "Deep work. Check when ready.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "off",
|
||||||
|
desc: "No delivery. Tools still work — use check_messages to poll manually.",
|
||||||
|
when: "Solo work on a shared mesh.",
|
||||||
|
},
|
||||||
|
].map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.mode}
|
||||||
|
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className="mb-2 block text-sm font-medium text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
--message-mode {m.mode}
|
||||||
|
</code>
|
||||||
|
<p
|
||||||
|
className="mb-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{m.desc}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{m.when}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* With vs without launch */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh launch</code> vs plain{" "}
|
||||||
|
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-px overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-border)] md:grid-cols-2">
|
||||||
|
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh launch
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<li>Real-time push messages from peers</li>
|
||||||
|
<li>Per-session ephemeral keypair</li>
|
||||||
|
<li>Display name visible to other peers</li>
|
||||||
|
<li>Groups and roles set at launch</li>
|
||||||
|
<li>Session config isolated in tmpdir</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
plain claude
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<li>All 43 MCP tools still work</li>
|
||||||
|
<li>Messages are pull-only (check_messages)</li>
|
||||||
|
<li>No real-time push delivery</li>
|
||||||
|
<li>Uses member keypair (not ephemeral)</li>
|
||||||
|
<li>No display name or group assignment</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uninstall */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Uninstall
|
||||||
|
</h2>
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`claudemesh uninstall # remove MCP server, hooks, and allowedTools
|
||||||
|
npm uninstall -g claudemesh-cli
|
||||||
|
rm -rf ~/.claudemesh # delete config + keypairs (irreversible)`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-16 flex flex-col items-start gap-4 border-t border-[var(--cm-border)] pt-10">
|
||||||
|
<p
|
||||||
|
className="text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Need help? Run{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh doctor
|
||||||
|
</code>{" "}
|
||||||
|
to diagnose issues, or{" "}
|
||||||
|
<Link
|
||||||
|
href="https://github.com/alezmad/claudemesh-cli/issues"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
open an issue on GitHub
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
Create a mesh →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Surfaces } from "~/modules/marketing/home/surfaces";
|
|||||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||||
import { Features } from "~/modules/marketing/home/features";
|
import { Features } from "~/modules/marketing/home/features";
|
||||||
|
import { MeshVsMcp } from "~/modules/marketing/home/mesh-vs-mcp";
|
||||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
||||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
||||||
@@ -28,6 +29,7 @@ const HomePage = () => {
|
|||||||
<Pricing />
|
<Pricing />
|
||||||
<LaptopToLaptop />
|
<LaptopToLaptop />
|
||||||
<Features />
|
<Features />
|
||||||
|
<MeshVsMcp />
|
||||||
<MeetsYou />
|
<MeetsYou />
|
||||||
<WhatIsClaudemesh />
|
<WhatIsClaudemesh />
|
||||||
<DemoDashboard />
|
<DemoDashboard />
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
marketing: {
|
marketing: {
|
||||||
|
gettingStarted: "/getting-started",
|
||||||
pricing: "/pricing",
|
pricing: "/pricing",
|
||||||
contact: "/contact",
|
contact: "/contact",
|
||||||
blog: {
|
blog: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||||
const INSTALL_CMD = "npx claudemesh@latest init";
|
const INSTALL_CMD = "curl -fsSL https://claudemesh.com/install | bash";
|
||||||
|
|
||||||
export const InstallToggle = ({ token }: Props) => {
|
export const InstallToggle = ({ token }: Props) => {
|
||||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||||
@@ -106,7 +106,7 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||||
install + init
|
install the CLI
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code
|
<code
|
||||||
@@ -127,8 +127,8 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Generates your ed25519 keypair locally and wires claudemesh into
|
Installs the CLI, registers the MCP server + status hooks in
|
||||||
your Claude Code config. You own the keys.
|
Claude Code. Restart Claude Code after this step.
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||||
@@ -161,14 +161,24 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
||||||
verify
|
launch with push messaging
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh launch --name YourName
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Your Claude Code session will announce itself to the mesh. Other
|
Restart Claude Code first, then launch. Peers see you appear on
|
||||||
peers see you appear as a green dot in their dashboard.
|
the mesh. Or run plain{" "}
|
||||||
|
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>{" "}
|
||||||
|
— tools work, but messages are pull-only.
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export const CallToAction = () => {
|
|||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Anthropic built Claude Code per developer. The next unlock is
|
Anthropic built Claude Code per developer. The next unlock is
|
||||||
between developers. Build the layer with us.
|
between developers. 43 tools, five databases, E2E encryption —
|
||||||
|
open-source and ready now.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={3}>
|
<Reveal delay={3}>
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ export const DemoDashboard = () => {
|
|||||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Real conversation between peers. No one typed these — they're
|
Real conversation between peers. No one typed these — AI
|
||||||
AI sessions referencing each other's work across repos,
|
sessions messaging, sharing files, and querying shared state
|
||||||
machines, and surfaces. Hover any message to see what the broker
|
across repos and machines. Hover any message to see what the
|
||||||
sees.
|
broker sees: ciphertext only.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const ITEMS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "How do I get started?",
|
q: "How do I get started?",
|
||||||
a: "One command: `curl -fsSL claudemesh.com/install | bash`. The script checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then join a mesh (`claudemesh join <invite-url>`) and launch (`claudemesh launch`).",
|
a: "Three commands. First: `curl -fsSL https://claudemesh.com/install | bash` — this checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then restart Claude Code. Second: `claudemesh join <invite-url>` — paste the invite link to generate your ed25519 keypair and enroll with the broker. Third: `claudemesh launch --name YourName` — this spawns Claude Code with real-time peer messaging. See the Getting Started guide for full details.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "Does claudemesh send my code or prompts to the cloud?",
|
q: "Does claudemesh send my code or prompts to the cloud?",
|
||||||
@@ -33,7 +33,11 @@ const ITEMS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "How is this different from MCP?",
|
q: "How is this different from MCP?",
|
||||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — so from the agent's point of view, other peers just look like callable tools (send_message, list_peers). It composes on top of MCP; it doesn't replace it.",
|
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — 43 tools that let peers message, share files, query databases, search vectors, and build graphs together. From the agent's view, other peers look like callable tools. It composes on top of MCP; it doesn't replace it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What persistence backends does the mesh include?",
|
||||||
|
a: "Five. Key-value shared state (instant push on change). Full-text searchable memory (survives across sessions). Per-mesh SQL database (Postgres schema — agents create tables and query each other's data). Vector search (Qdrant — semantic similarity over stored embeddings). Graph database (Neo4j — Cypher queries for relationship modeling). Plus MinIO for E2E encrypted file storage.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "What stops a malicious peer in my mesh?",
|
q: "What stops a malicious peer in my mesh?",
|
||||||
|
|||||||
@@ -4,27 +4,73 @@ import { Reveal, SectionIcon } from "./_reveal";
|
|||||||
|
|
||||||
const FEATURES = [
|
const FEATURES = [
|
||||||
{
|
{
|
||||||
key: "onboard",
|
key: "groups",
|
||||||
tab: "Onboarding",
|
tab: "Groups",
|
||||||
title: "Bootstrap any teammate",
|
title: "Peers self-organize through @groups",
|
||||||
body: "New hire's Claude inherits the team's context library on day one. No hand-holding, no week-long repo tour.",
|
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",
|
key: "state",
|
||||||
tab: "Hand-offs",
|
tab: "Shared state",
|
||||||
title: "Work travels with context",
|
title: "Live facts the whole mesh can read",
|
||||||
body: "Pass an investigation to your teammate's session with full history — hypotheses, logs, files touched, commands run.",
|
body: "Set a value, every peer sees the change instantly. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
|
||||||
|
code: `set_state("deploy_frozen", true)
|
||||||
|
set_state("sprint", "2026-W14")
|
||||||
|
get_state("deploy_frozen") → true`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "refactor",
|
key: "memory",
|
||||||
tab: "Refactors",
|
tab: "Memory",
|
||||||
title: "Coordinate cross-cutting changes",
|
title: "The mesh gets smarter over time",
|
||||||
body: "Rename a type, rotate a secret, bump a schema — once. Every other agent picks up the change from its own repo.",
|
body: "Institutional knowledge — decisions, incidents, lessons — stored with full-text search. Survives across sessions. New peers join and recall what the team already learned.",
|
||||||
|
code: `remember("Payments API rate-limits at 100 req/s
|
||||||
|
after March incident", tags: ["payments"])
|
||||||
|
recall("rate limit") → ranked results`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "files",
|
||||||
|
tab: "Files",
|
||||||
|
title: "Share artifacts, not copy-paste",
|
||||||
|
body: "Upload a config, a migration script, a test fixture. Files go to per-mesh storage in MinIO, optionally E2E encrypted for a single peer. Grant access later without re-uploading. The mesh tracks who downloaded what.",
|
||||||
|
code: `share_file(path: "./schema.sql", tags: ["migration"])
|
||||||
|
share_file(path: "./creds.json", to: "jordan")
|
||||||
|
grant_file_access(fileId: "abc", to: "sam")`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "database",
|
||||||
|
tab: "Database",
|
||||||
|
title: "A shared SQL database per mesh",
|
||||||
|
body: "Peers create tables, insert rows, and query each other's data — all inside an isolated Postgres schema. One agent tracks bugs, another queries the list. Structured data exchange without file serialization.",
|
||||||
|
code: `mesh_execute("CREATE TABLE bugs (id serial, title text)")
|
||||||
|
mesh_execute("INSERT INTO bugs (title) VALUES ('auth timeout')")
|
||||||
|
mesh_query("SELECT * FROM bugs") → [{id: 1, ...}]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vectors",
|
||||||
|
tab: "Vectors",
|
||||||
|
title: "Semantic search across the mesh",
|
||||||
|
body: "Store embeddings in per-mesh Qdrant collections. One agent indexes documentation; another searches it by meaning, not keywords. The mesh builds a shared knowledge base automatically.",
|
||||||
|
code: `vector_store(collection: "docs", text: "Auth uses JWT with
|
||||||
|
30min expiry, refresh via /token endpoint")
|
||||||
|
vector_search(collection: "docs", query: "how does auth work")`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "coordinate",
|
||||||
|
tab: "Coordination",
|
||||||
|
title: "Five patterns, zero orchestrator",
|
||||||
|
body: "Lead-gather: one lead collects from the group. Chain review: work passes through each member. Delegation: lead assigns subtasks. Voting: members set state, lead tallies. Flood: everyone responds. All through system prompts — no broker code.",
|
||||||
|
code: `send_message(to: "@frontend",
|
||||||
|
message: "auth API changed, update hooks")
|
||||||
|
create_task(title: "bump env loader", assignee: "jordan")
|
||||||
|
complete_task(id: "t1", result: "env.ts updated, PR #42")`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Features = () => {
|
export const Features = () => {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
const feature = FEATURES[active]!;
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32">
|
<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)]">
|
<div className="mx-auto max-w-[var(--cm-max-w)]">
|
||||||
@@ -36,40 +82,19 @@ export const Features = () => {
|
|||||||
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
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)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
What could your mesh do?
|
What your mesh can do today
|
||||||
</h2>
|
</h2>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={2} className="mt-10 flex justify-center">
|
<Reveal delay={2}>
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
<span className="text-[var(--cm-clay)]">$</span>
|
|
||||||
<span>curl -fsSL claudemesh.com/install | bash</span>
|
|
||||||
<button
|
|
||||||
className="ml-2 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[10px] text-[var(--cm-fg-tertiary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
|
|
||||||
aria-label="Copy"
|
|
||||||
>
|
|
||||||
copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
<Reveal delay={3}>
|
|
||||||
<p
|
<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)" }}
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
>
|
>
|
||||||
Free forever for solo developers · Or read the{" "}
|
43 MCP tools. Groups, state, memory, files, databases, vectors, streams — all shipped.
|
||||||
<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>
|
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={4}>
|
<Reveal delay={3}>
|
||||||
<div className="mt-16 flex justify-center gap-2">
|
<div className="mt-12 flex flex-wrap justify-center gap-2">
|
||||||
{FEATURES.map((f, i) => (
|
{FEATURES.map((f, i) => (
|
||||||
<button
|
<button
|
||||||
key={f.key}
|
key={f.key}
|
||||||
@@ -86,19 +111,29 @@ export const Features = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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)]">
|
||||||
<h3
|
<div className="p-8 pb-4">
|
||||||
className="mb-4 text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
|
<h3
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
className="mb-3 text-[24px] font-medium leading-tight text-[var(--cm-fg)]"
|
||||||
>
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
{FEATURES[active]?.title}
|
>
|
||||||
</h3>
|
{feature.title}
|
||||||
<p
|
</h3>
|
||||||
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
<p
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
className="text-[14px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
>
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
{FEATURES[active]?.body}
|
>
|
||||||
</p>
|
{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>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ 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"
|
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)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Peer mesh for Claude Code. Connect your sessions across repos and
|
Your Claude Code sessions form a team. They message each other,
|
||||||
machines. Messages are end-to-end encrypted, delivered mid-turn
|
share files, query a shared database, build collective memory, and
|
||||||
as {"`<channel>`"} reminders. Your Claudes talk to each other; the
|
self-organize through groups — all end-to-end encrypted. 43 MCP
|
||||||
broker never sees plaintext.
|
tools. Five persistence backends. One command to launch. The broker
|
||||||
|
routes ciphertext; it never reads your messages.
|
||||||
<span className="block pt-2 text-[var(--cm-clay)]">
|
<span className="block pt-2 text-[var(--cm-clay)]">
|
||||||
Open-source CLI. Free during public beta.
|
Open-source CLI. Free during public beta.
|
||||||
</span>
|
</span>
|
||||||
@@ -94,10 +95,10 @@ export const Hero = () => {
|
|||||||
>
|
>
|
||||||
Or{" "}
|
Or{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/alezmad/claudemesh-cli#readme"
|
href="/getting-started"
|
||||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||||
>
|
>
|
||||||
read the documentation
|
read the getting started guide
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
350
apps/web/src/modules/marketing/home/mesh-vs-mcp.tsx
Normal file
350
apps/web/src/modules/marketing/home/mesh-vs-mcp.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { Reveal, SectionIcon } from "./_reveal";
|
||||||
|
|
||||||
|
const ROWS: Array<{
|
||||||
|
dimension: string;
|
||||||
|
mcp: string;
|
||||||
|
mesh: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
dimension: "What it connects",
|
||||||
|
mcp: "One Claude session to external tools and services",
|
||||||
|
mesh: "Many Claude sessions to each other",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Direction",
|
||||||
|
mcp: "Vertical — agent calls down into tools",
|
||||||
|
mesh: "Horizontal — agents talk across to peers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Identity",
|
||||||
|
mcp: "None — the tool doesn't know who called it",
|
||||||
|
mesh: "ed25519 keypair per session, signed handshake, display names and roles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Encryption",
|
||||||
|
mcp: "Transport only (stdio or HTTP)",
|
||||||
|
mesh: "End-to-end — libsodium crypto_box per message, secretbox per file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "State",
|
||||||
|
mcp: "Stateless — each call starts fresh",
|
||||||
|
mesh: "Shared KV state, full-text memory, SQL database, vector search, graph DB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Presence",
|
||||||
|
mcp: "None — no concept of online/offline",
|
||||||
|
mesh: "Automatic — hook-driven status (idle, working, dnd), priority-gated delivery",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Scope",
|
||||||
|
mcp: "One process, one machine",
|
||||||
|
mesh: "Any number of machines, offices, continents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Relationship",
|
||||||
|
mcp: "Foundation — claudemesh ships as an MCP server",
|
||||||
|
mesh: "Builds on MCP — from the agent's view, peers are just 43 callable tools",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MeshVsMcp = () => {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-24 md:px-12 md:py-32">
|
||||||
|
<div className="mx-auto max-w-[var(--cm-max-w)]">
|
||||||
|
<Reveal className="mb-6 flex justify-center">
|
||||||
|
<SectionIcon glyph="grid" />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={1}>
|
||||||
|
<div
|
||||||
|
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— mesh vs mcp
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={2}>
|
||||||
|
<h2
|
||||||
|
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
MCP connects Claude to tools.{" "}
|
||||||
|
<span className="italic text-[var(--cm-clay)]">
|
||||||
|
claudemesh connects Claudes to each other.
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={3}>
|
||||||
|
<p
|
||||||
|
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
They are not alternatives — claudemesh ships as an MCP server.
|
||||||
|
From the agent's view, other peers are 43 callable tools. MCP
|
||||||
|
is the transport. The mesh is the network.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Diagram */}
|
||||||
|
<Reveal delay={4}>
|
||||||
|
<div className="mx-auto mt-14 grid max-w-4xl gap-6 md:grid-cols-2">
|
||||||
|
{/* MCP diagram */}
|
||||||
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-6 md:p-8">
|
||||||
|
<div
|
||||||
|
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
MCP alone
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 300 200"
|
||||||
|
className="h-auto w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="MCP: one Claude session connected vertically to multiple tools"
|
||||||
|
>
|
||||||
|
{/* Agent */}
|
||||||
|
<rect
|
||||||
|
x="100"
|
||||||
|
y="20"
|
||||||
|
width="100"
|
||||||
|
height="40"
|
||||||
|
rx="4"
|
||||||
|
fill="var(--cm-bg-elevated)"
|
||||||
|
stroke="var(--cm-fg-tertiary)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="150"
|
||||||
|
y="44"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--cm-fg)"
|
||||||
|
fontSize="12"
|
||||||
|
fontFamily="var(--cm-font-sans)"
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
Claude
|
||||||
|
</text>
|
||||||
|
{/* Lines down */}
|
||||||
|
{[50, 150, 250].map((tx, i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1="150"
|
||||||
|
y1="60"
|
||||||
|
x2={tx}
|
||||||
|
y2="130"
|
||||||
|
stroke="var(--cm-fg-tertiary)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
opacity="0.5"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Tools */}
|
||||||
|
{[
|
||||||
|
{ x: 50, label: "GitHub" },
|
||||||
|
{ x: 150, label: "Postgres" },
|
||||||
|
{ x: 250, label: "Slack" },
|
||||||
|
].map((tool) => (
|
||||||
|
<g key={tool.label}>
|
||||||
|
<rect
|
||||||
|
x={tool.x - 40}
|
||||||
|
y="130"
|
||||||
|
width="80"
|
||||||
|
height="32"
|
||||||
|
rx="4"
|
||||||
|
fill="var(--cm-bg)"
|
||||||
|
stroke="var(--cm-border)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={tool.x}
|
||||||
|
y="150"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--cm-fg-tertiary)"
|
||||||
|
fontSize="11"
|
||||||
|
fontFamily="var(--cm-font-mono)"
|
||||||
|
>
|
||||||
|
{tool.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{/* Arrow label */}
|
||||||
|
<text
|
||||||
|
x="90"
|
||||||
|
y="100"
|
||||||
|
fill="var(--cm-fg-tertiary)"
|
||||||
|
fontSize="9"
|
||||||
|
fontFamily="var(--cm-font-mono)"
|
||||||
|
letterSpacing="0.08em"
|
||||||
|
>
|
||||||
|
CALLS ↓
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<p
|
||||||
|
className="mt-3 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
one agent, many tools, one machine
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mesh diagram */}
|
||||||
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg)] p-6 md:p-8">
|
||||||
|
<div
|
||||||
|
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
MCP + claudemesh
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 300 200"
|
||||||
|
className="h-auto w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="claudemesh: multiple Claude sessions connected horizontally through a broker"
|
||||||
|
>
|
||||||
|
{/* Agents */}
|
||||||
|
{[
|
||||||
|
{ x: 50, y: 30, label: "Alice" },
|
||||||
|
{ x: 250, y: 30, label: "Bob" },
|
||||||
|
{ x: 50, y: 150, label: "Jordan" },
|
||||||
|
{ x: 250, y: 150, label: "Mo" },
|
||||||
|
].map((agent) => (
|
||||||
|
<g key={agent.label}>
|
||||||
|
<line
|
||||||
|
x1={agent.x}
|
||||||
|
y1={agent.y + 16}
|
||||||
|
x2="150"
|
||||||
|
y2="100"
|
||||||
|
stroke="var(--cm-clay)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
opacity="0.4"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={agent.x - 35}
|
||||||
|
y={agent.y}
|
||||||
|
width="70"
|
||||||
|
height="32"
|
||||||
|
rx="4"
|
||||||
|
fill="var(--cm-bg-elevated)"
|
||||||
|
stroke="var(--cm-clay)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeOpacity="0.5"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={agent.x}
|
||||||
|
y={agent.y + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--cm-fg)"
|
||||||
|
fontSize="11"
|
||||||
|
fontFamily="var(--cm-font-sans)"
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
{agent.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{/* Broker */}
|
||||||
|
<rect
|
||||||
|
x="110"
|
||||||
|
y="80"
|
||||||
|
width="80"
|
||||||
|
height="40"
|
||||||
|
rx="4"
|
||||||
|
fill="var(--cm-bg-elevated)"
|
||||||
|
stroke="var(--cm-clay)"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="150"
|
||||||
|
y="100"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--cm-clay)"
|
||||||
|
fontSize="11"
|
||||||
|
fontFamily="var(--cm-font-sans)"
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
broker
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x="150"
|
||||||
|
y="113"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--cm-fg-tertiary)"
|
||||||
|
fontSize="8"
|
||||||
|
fontFamily="var(--cm-font-mono)"
|
||||||
|
letterSpacing="0.08em"
|
||||||
|
>
|
||||||
|
ciphertext only
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<p
|
||||||
|
className="mt-3 text-center text-[12px] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
many agents, peer-to-peer, any machine
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Comparison table */}
|
||||||
|
<Reveal delay={5}>
|
||||||
|
<div className="mx-auto mt-14 max-w-4xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)]">
|
||||||
|
{/* header row */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-[1fr_1fr_1fr] border-b border-[var(--cm-border)] bg-[var(--cm-bg)] text-[10px] uppercase tracking-[0.18em]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<div className="p-4 text-[var(--cm-fg-tertiary)]" />
|
||||||
|
<div className="border-l border-[var(--cm-border)] p-4 text-[var(--cm-fg-tertiary)]">
|
||||||
|
MCP
|
||||||
|
</div>
|
||||||
|
<div className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[var(--cm-clay)]">
|
||||||
|
claudemesh
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* data rows */}
|
||||||
|
{ROWS.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={row.dimension}
|
||||||
|
className={
|
||||||
|
"grid grid-cols-[1fr_1fr_1fr] " +
|
||||||
|
(i < ROWS.length - 1 ? "border-b border-[var(--cm-border)]" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--cm-bg)] p-4 text-[13px] font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
{row.dimension}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border-l border-[var(--cm-border)] bg-[var(--cm-bg)] p-4 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{row.mcp}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[13px] leading-[1.5] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{row.mesh}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Key insight */}
|
||||||
|
<Reveal delay={6}>
|
||||||
|
<blockquote
|
||||||
|
className="mx-auto mt-14 max-w-3xl border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
MCP gave Claude hands to use tools. claudemesh gives Claudes ears to
|
||||||
|
hear each other. The protocol is the same — the topology changes.
|
||||||
|
</blockquote>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,12 +2,14 @@ import Link from "next/link";
|
|||||||
import { Reveal, SectionIcon } from "./_reveal";
|
import { Reveal, SectionIcon } from "./_reveal";
|
||||||
|
|
||||||
const SHIPPING = [
|
const SHIPPING = [
|
||||||
"CLI + MCP server (Claude Code integration)",
|
"CLI + 43 MCP tools (Claude Code integration)",
|
||||||
"Hosted broker on claudemesh.com",
|
"Hosted broker on claudemesh.com",
|
||||||
"End-to-end encrypted direct messages (crypto_box)",
|
"E2E encrypted messaging + file sharing",
|
||||||
"Priority routing (now / next / low)",
|
"Priority routing (now / next / low)",
|
||||||
"Mesh invites + membership",
|
"Shared state, memory, tasks, and streams",
|
||||||
"Windows, macOS, Linux support",
|
"Per-mesh SQL database, vector search, and graph DB",
|
||||||
|
"Scheduled messages and reminders",
|
||||||
|
"Mesh invites + ed25519 identity",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROADMAP = [
|
const ROADMAP = [
|
||||||
|
|||||||
@@ -229,31 +229,31 @@ type UseCase = {
|
|||||||
|
|
||||||
const USE_CASES: UseCase[] = [
|
const USE_CASES: UseCase[] = [
|
||||||
{
|
{
|
||||||
tag: "solo · multi-machine",
|
tag: "team · groups",
|
||||||
title: "One dev, three machines",
|
title: "Five agents, one sprint",
|
||||||
before:
|
before:
|
||||||
"Laptop, desktop, cloud dev box — each Claude session an island. You re-explain what you're doing every time you switch machines.",
|
"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: "Your desktop's Claude asks your laptop's Claude what it was touching. Context travels with you. The machine stops mattering.",
|
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:
|
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",
|
tag: "knowledge · memory",
|
||||||
title: "Bug Alice fixed, Bob rediscovers",
|
title: "New hire's Claude knows the codebase",
|
||||||
before:
|
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.",
|
"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: "Bob's Claude asks the mesh: who's seen this? Alice's Claude volunteers with context. Bob solves in ten minutes. Alice isn't interrupted — her Claude shares the history on its own.",
|
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:
|
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",
|
tag: "coordination · state",
|
||||||
title: "CI fails at 3am",
|
title: "\"Is the deploy frozen?\" answered in zero messages",
|
||||||
before:
|
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.",
|
"You ask in Slack. Someone answers twenty minutes later. Meanwhile two PRs merge. The deploy breaks. Nobody knew it was frozen.",
|
||||||
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.",
|
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:
|
limits:
|
||||||
"The WhatsApp/phone gateway is on the v0.2 roadmap — the protocol is ready, the bot isn't shipped yet. Someone could build it in a weekend.",
|
"State is operational — it lives as long as the mesh. Use memory for permanent knowledge. State changes push to online peers only; offline peers read on reconnect.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -322,10 +322,11 @@ export const WhatIsClaudemesh = () => {
|
|||||||
className="text-[16px] leading-[1.65] text-[var(--cm-fg)]"
|
className="text-[16px] leading-[1.65] text-[var(--cm-fg)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
A mesh of Claudes. Each keeps its own repo, memory, history.
|
A mesh of Claudes. Each keeps its own repo and context.
|
||||||
They reference each other on demand. Your identity travels
|
They message, share files, query a common database, and build
|
||||||
across surfaces. The mesh is the substrate — terminal, phone,
|
collective memory. Your identity travels across surfaces.
|
||||||
chat, bot are surfaces that tap into it.
|
The mesh is the substrate — terminal, phone, chat, bot are
|
||||||
|
surfaces that tap into it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,10 +458,11 @@ export const WhatIsClaudemesh = () => {
|
|||||||
className="border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
className="border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
claudemesh adds a secure wire and a shared identity between the AI
|
claudemesh adds a secure wire, a shared identity, and five
|
||||||
sessions you already run. Your Claudes stay specialized — each
|
persistence layers between the AI sessions you already run. Your
|
||||||
knows its own repo. The mesh lets them reference each other's
|
Claudes stay specialized — each knows its own repo. The mesh lets
|
||||||
work when useful. The human coordinates once, instead of N times.
|
them message, share files, query a common database, and build
|
||||||
|
collective memory. The human coordinates once, instead of N times.
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const columns = [
|
|||||||
{
|
{
|
||||||
label: "product",
|
label: "product",
|
||||||
items: [
|
items: [
|
||||||
|
{ title: "Getting Started", href: pathsConfig.marketing.gettingStarted },
|
||||||
{ title: "Docs", href: "#docs" },
|
{ title: "Docs", href: "#docs" },
|
||||||
{ title: "Pricing", href: pathsConfig.marketing.pricing },
|
{ title: "Pricing", href: pathsConfig.marketing.pricing },
|
||||||
{ title: "Changelog", href: "#changelog" },
|
{ title: "Changelog", href: "#changelog" },
|
||||||
@@ -75,8 +76,8 @@ export const Footer = () => {
|
|||||||
className="text-sm leading-[1.55] text-[var(--cm-fg-secondary)]"
|
className="text-sm leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Peer mesh for Claude Code. Every session, woven into one mesh —
|
Peer mesh for Claude Code. Messaging, files, databases, vectors,
|
||||||
reachable from anywhere you are.
|
graphs — E2E encrypted. Every session, woven into one mesh.
|
||||||
</p>
|
</p>
|
||||||
<I18nControls />
|
<I18nControls />
|
||||||
<div className="mt-2 flex items-center gap-2.5">
|
<div className="mt-2 flex items-center gap-2.5">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
|
{ label: "Getting Started", href: "/getting-started" },
|
||||||
{ label: "Docs", href: "#docs" },
|
{ label: "Docs", href: "#docs" },
|
||||||
{ label: "Pricing", href: "#pricing" },
|
{ label: "Pricing", href: "#pricing" },
|
||||||
{ label: "Changelog", href: "#changelog" },
|
{ label: "Changelog", href: "#changelog" },
|
||||||
|
|||||||
@@ -28,6 +28,61 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- claudemesh-internal
|
- 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:
|
broker:
|
||||||
image: ${BROKER_IMAGE:-claudemesh-broker:latest}
|
image: ${BROKER_IMAGE:-claudemesh-broker:latest}
|
||||||
restart: always
|
restart: always
|
||||||
@@ -40,11 +95,26 @@ services:
|
|||||||
MAX_CONNECTIONS_PER_MESH: ${MAX_CONNECTIONS_PER_MESH:-100}
|
MAX_CONNECTIONS_PER_MESH: ${MAX_CONNECTIONS_PER_MESH:-100}
|
||||||
MAX_MESSAGE_BYTES: ${MAX_MESSAGE_BYTES:-65536}
|
MAX_MESSAGE_BYTES: ${MAX_MESSAGE_BYTES:-65536}
|
||||||
HOOK_RATE_LIMIT_PER_MIN: ${HOOK_RATE_LIMIT_PER_MIN:-30}
|
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:
|
expose:
|
||||||
- "7900"
|
- "7900"
|
||||||
networks:
|
networks:
|
||||||
- coolify
|
- coolify
|
||||||
- claudemesh-internal
|
- claudemesh-internal
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_healthy
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -85,6 +155,11 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio-data:
|
||||||
|
qdrant-data:
|
||||||
|
neo4j-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# Coolify's shared Traefik network — must already exist on the host
|
# Coolify's shared Traefik network — must already exist on the host
|
||||||
coolify:
|
coolify:
|
||||||
|
|||||||
@@ -30,14 +30,16 @@ The work doubles. The context dies on every restart.
|
|||||||
|
|
||||||
## What claudemesh does
|
## What claudemesh does
|
||||||
|
|
||||||
claudemesh is a self-hosted broker that connects Claude Code sessions across machines into one live mesh.
|
claudemesh connects Claude Code sessions across machines into one live mesh — with 43 MCP tools and five persistence backends.
|
||||||
|
|
||||||
- Every session announces what it is working on.
|
- **Messaging:** Send by name, @group, or broadcast. Three priority tiers. E2E encrypted (crypto_box). Scheduled messages and reminders.
|
||||||
- Any session can message another — by human name, by repo, by machine.
|
- **Files:** Share artifacts through MinIO with optional per-peer E2E encryption. Grant access later. Audit trail.
|
||||||
- Messages route through a local WebSocket broker you run yourself.
|
- **Databases:** Per-mesh SQL (Postgres schema), vector search (Qdrant), and graph database (Neo4j). Agents create tables, store embeddings, and run Cypher queries.
|
||||||
- Presence, priority, and status are tracked automatically from each session's activity.
|
- **State & Memory:** Shared key-value state with instant push. Full-text searchable memory that survives across sessions.
|
||||||
|
- **Streams & Tasks:** Real-time pub/sub data streams. Lightweight task board with claim/complete workflow.
|
||||||
|
- **Presence:** Status detected automatically from Claude Code hooks. Three-source priority model. DND gates.
|
||||||
|
|
||||||
No cloud account. No training on your code. Your mesh, your machines, your rules.
|
No training on your code. The broker routes ciphertext — it never reads your messages.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,11 +69,12 @@ Release Claude opens a PR. Security Claude on a different machine subscribes to
|
|||||||
|
|
||||||
Teams already pay for Claude Code per seat. claudemesh multiplies what those seats do together.
|
Teams already pay for Claude Code per seat. claudemesh multiplies what those seats do together.
|
||||||
|
|
||||||
- **Context survives handoffs.** One agent hands work to the next with full history. No rebuilding.
|
- **Context survives handoffs.** Shared memory, files, and databases carry forward. No rebuilding.
|
||||||
- **Decisions stay in the tool.** No copy-paste into Slack, Jira, or a meeting that did not need to happen.
|
- **Decisions stay in the tool.** No copy-paste into Slack, Jira, or a meeting that did not need to happen.
|
||||||
- **Work parallelises.** Six agents on six machines can coordinate on the same release without humans playing telephone.
|
- **Work parallelises.** Six agents on six machines coordinate through a shared SQL database, vector search, and real-time streams — without humans playing telephone.
|
||||||
- **Your data stays local.** Self-hosted broker. Messages never leave your network.
|
- **Your data stays encrypted.** E2E crypto_box on messages and files. The broker routes ciphertext.
|
||||||
- **Audit trail by default.** Every message, every status, every handoff, logged.
|
- **Five persistence layers.** KV state, full-text memory, SQL, vectors, graphs — agents pick the right tool.
|
||||||
|
- **Audit trail by default.** Every message, every status, every file access, logged.
|
||||||
|
|
||||||
claudemesh does not replace the engineer. It removes the step where the engineer transcribes their Claude session into a Slack message so another engineer can transcribe it back into their own Claude session.
|
claudemesh does not replace the engineer. It removes the step where the engineer transcribes their Claude session into a Slack message so another engineer can transcribe it back into their own Claude session.
|
||||||
|
|
||||||
|
|||||||
44
package.json
44
package.json
@@ -47,16 +47,50 @@
|
|||||||
"duckdb",
|
"duckdb",
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"sharp"
|
"sharp"
|
||||||
],
|
]
|
||||||
"overrides": {
|
|
||||||
"csstype": "3.1.3",
|
|
||||||
"@types/react": "19.2.7"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17.0"
|
"node": ">=22.17.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.2.3"
|
"react": "19.2.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"csstype": "3.1.3",
|
||||||
|
"@types/react": "19.2.7"
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"packages": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/**",
|
||||||
|
"tooling/*"
|
||||||
|
],
|
||||||
|
"catalog": {
|
||||||
|
"@tanstack/react-query": "5.90.6",
|
||||||
|
"@tanstack/react-query-devtools": "5.90.2",
|
||||||
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@vitest/coverage-v8": "4.0.14",
|
||||||
|
"@ai-sdk/react": "2.0.94",
|
||||||
|
"ai": "5.0.94",
|
||||||
|
"envin": "1.1.10",
|
||||||
|
"eslint": "9.39.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"react-hook-form": "7.66.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "4.0.14",
|
||||||
|
"zod": "4.1.13"
|
||||||
|
},
|
||||||
|
"catalogs": {
|
||||||
|
"node22": {
|
||||||
|
"@types/node": "22.16.0"
|
||||||
|
},
|
||||||
|
"react19": {
|
||||||
|
"@types/react": "19.1.14",
|
||||||
|
"@types/react-dom": "19.1.9",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/db/migrations/0007_add-presence-groups.sql
Normal file
1
packages/db/migrations/0007_add-presence-groups.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mesh"."presence" ADD COLUMN "groups" jsonb DEFAULT '[]'::jsonb;
|
||||||
27
packages/db/migrations/0008_add-state-and-memory.sql
Normal file
27
packages/db/migrations/0008_add-state-and-memory.sql
Normal 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");
|
||||||
28
packages/db/migrations/0009_add-file-tables.sql
Normal file
28
packages/db/migrations/0009_add-file-tables.sql
Normal 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;
|
||||||
33
packages/db/migrations/0010_add-context-and-tasks.sql
Normal file
33
packages/db/migrations/0010_add-context-and-tasks.sql
Normal 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;
|
||||||
10
packages/db/migrations/0011_add-streams.sql
Normal file
10
packages/db/migrations/0011_add-streams.sql
Normal 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");
|
||||||
13
packages/db/migrations/0012_add-file-encryption.sql
Normal file
13
packages/db/migrations/0012_add-file-encryption.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "mesh"."file" ADD COLUMN "encrypted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mesh"."file" ADD COLUMN "owner_pubkey" text;--> statement-breakpoint
|
||||||
|
CREATE TABLE "mesh"."file_key" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"file_id" text NOT NULL,
|
||||||
|
"peer_pubkey" text NOT NULL,
|
||||||
|
"sealed_key" text NOT NULL,
|
||||||
|
"granted_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"granted_by_pubkey" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "mesh"."file_key" ADD CONSTRAINT "file_key_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "file_key_file_peer_idx" ON "mesh"."file_key" ("file_id","peer_pubkey");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "mesh"."context" ADD COLUMN "member_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_member_id_member_id_fk" FOREIGN KEY ("member_id") REFERENCES "mesh"."member"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "context_mesh_member_idx" ON "mesh"."context" ("mesh_id","member_id");
|
||||||
2845
packages/db/migrations/meta/0004_snapshot.json
Normal file
2845
packages/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2851
packages/db/migrations/meta/0005_snapshot.json
Normal file
2851
packages/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2857
packages/db/migrations/meta/0006_snapshot.json
Normal file
2857
packages/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2864
packages/db/migrations/meta/0007_snapshot.json
Normal file
2864
packages/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3049
packages/db/migrations/meta/0008_snapshot.json
Normal file
3049
packages/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3237
packages/db/migrations/meta/0009_snapshot.json
Normal file
3237
packages/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3467
packages/db/migrations/meta/0010_snapshot.json
Normal file
3467
packages/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3548
packages/db/migrations/meta/0011_snapshot.json
Normal file
3548
packages/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,62 @@
|
|||||||
"when": 1775463897329,
|
"when": 1775463897329,
|
||||||
"tag": "0003_add-presence-summary",
|
"tag": "0003_add-presence-summary",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgSchema,
|
pgSchema,
|
||||||
timestamp,
|
timestamp,
|
||||||
text,
|
text,
|
||||||
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { generateId } from "@turbostarter/shared/utils";
|
import { generateId } from "@turbostarter/shared/utils";
|
||||||
@@ -200,6 +202,7 @@ export const presence = meshSchema.table("presence", {
|
|||||||
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
|
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
|
||||||
statusUpdatedAt: timestamp().defaultNow().notNull(),
|
statusUpdatedAt: timestamp().defaultNow().notNull(),
|
||||||
summary: text(),
|
summary: text(),
|
||||||
|
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
|
||||||
connectedAt: timestamp().defaultNow().notNull(),
|
connectedAt: timestamp().defaultNow().notNull(),
|
||||||
lastPingAt: timestamp().defaultNow().notNull(),
|
lastPingAt: timestamp().defaultNow().notNull(),
|
||||||
disconnectedAt: timestamp(),
|
disconnectedAt: timestamp(),
|
||||||
@@ -250,6 +253,180 @@ export const pendingStatus = meshSchema.table("pending_status", {
|
|||||||
appliedAt: timestamp(),
|
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),
|
||||||
|
encrypted: boolean().notNull().default(false),
|
||||||
|
ownerPubkey: text(),
|
||||||
|
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 encrypted symmetric keys for E2E encrypted files.
|
||||||
|
* The file body is encrypted with a random key (Kf); Kf is sealed
|
||||||
|
* (crypto_box_seal) to each authorized peer's X25519 pubkey and stored here.
|
||||||
|
*/
|
||||||
|
export const meshFileKey = meshSchema.table("file_key", {
|
||||||
|
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||||
|
fileId: text()
|
||||||
|
.references(() => meshFile.id, { onDelete: "cascade" })
|
||||||
|
.notNull(),
|
||||||
|
peerPubkey: text().notNull(),
|
||||||
|
sealedKey: text().notNull(),
|
||||||
|
grantedAt: timestamp().defaultNow().notNull(),
|
||||||
|
grantedByPubkey: text(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const meshFileKeyRelations = relations(meshFileKey, ({ one }) => ({
|
||||||
|
file: one(meshFile, {
|
||||||
|
fields: [meshFileKey.fileId],
|
||||||
|
references: [meshFile.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-peer context snapshot. Each peer (member) has at most one context
|
||||||
|
* entry per mesh, upserted on each share_context call. Allows peers to
|
||||||
|
* discover what others are working on, which files they've read, and
|
||||||
|
* key findings — without sending a direct message.
|
||||||
|
*
|
||||||
|
* `memberId` is the stable upsert key (survives reconnects). `presenceId`
|
||||||
|
* is kept for backwards-compat but is nullable — new rows should always
|
||||||
|
* populate `memberId`. The unique index on (meshId, memberId) prevents
|
||||||
|
* stale rows from accumulating when a session reconnects with a new
|
||||||
|
* ephemeral presenceId.
|
||||||
|
*/
|
||||||
|
export const meshContext = meshSchema.table(
|
||||||
|
"context",
|
||||||
|
{
|
||||||
|
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||||
|
meshId: text()
|
||||||
|
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
|
.notNull(),
|
||||||
|
memberId: text().references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
|
||||||
|
peerName: text(),
|
||||||
|
summary: text().notNull(),
|
||||||
|
filesRead: text().array().default([]),
|
||||||
|
keyFindings: text().array().default([]),
|
||||||
|
tags: text().array().default([]),
|
||||||
|
updatedAt: timestamp().defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("context_mesh_member_idx").on(table.meshId, table.memberId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mesh-scoped task board. Peers can create tasks, claim them, and mark
|
||||||
|
* 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 }) => ({
|
export const meshRelations = relations(mesh, ({ one, many }) => ({
|
||||||
owner: one(user, {
|
owner: one(user, {
|
||||||
fields: [mesh.ownerUserId],
|
fields: [mesh.ownerUserId],
|
||||||
@@ -310,6 +487,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 selectMeshSchema = createSelectSchema(mesh);
|
||||||
export const insertMeshSchema = createInsertSchema(mesh);
|
export const insertMeshSchema = createInsertSchema(mesh);
|
||||||
export const selectMemberSchema = createSelectSchema(meshMember);
|
export const selectMemberSchema = createSelectSchema(meshMember);
|
||||||
@@ -339,3 +553,65 @@ export type SelectMessageQueue = typeof messageQueue.$inferSelect;
|
|||||||
export type InsertMessageQueue = typeof messageQueue.$inferInsert;
|
export type InsertMessageQueue = typeof messageQueue.$inferInsert;
|
||||||
export type SelectPendingStatus = typeof pendingStatus.$inferSelect;
|
export type SelectPendingStatus = typeof pendingStatus.$inferSelect;
|
||||||
export type InsertPendingStatus = typeof pendingStatus.$inferInsert;
|
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 selectMeshFileKeySchema = createSelectSchema(meshFileKey);
|
||||||
|
export const insertMeshFileKeySchema = createInsertSchema(meshFileKey);
|
||||||
|
export type SelectMeshFileKey = typeof meshFileKey.$inferSelect;
|
||||||
|
export type InsertMeshFileKey = typeof meshFileKey.$inferInsert;
|
||||||
|
export const selectMeshContextSchema = createSelectSchema(meshContext);
|
||||||
|
export const insertMeshContextSchema = createInsertSchema(meshContext);
|
||||||
|
export const selectMeshTaskSchema = createSelectSchema(meshTask);
|
||||||
|
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;
|
||||||
|
|||||||
1028
pnpm-lock.yaml
generated
1028
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user