Compare commits
63 Commits
v0.5.9
...
56b1cc0756
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b1cc0756 | ||
|
|
fc8a7edc23 | ||
|
|
e09671cdcb | ||
|
|
32fc4a0c98 | ||
|
|
b315b31cc9 | ||
|
|
21cb6efced | ||
|
|
125b576e2c | ||
|
|
3641618391 | ||
|
|
a92cf6b629 | ||
|
|
2c9c8c7b6c | ||
|
|
98fda20ab6 | ||
|
|
025a53a70c | ||
|
|
b55cf269a4 | ||
|
|
504111c50c | ||
|
|
05d9b56f28 | ||
|
|
c8cb1e3ea5 | ||
|
|
86a258301f | ||
|
|
7e102a235b | ||
|
|
5563f90733 | ||
|
|
b3b9972e60 | ||
|
|
fe9285351b | ||
|
|
08e289a5e3 | ||
|
|
7d432b3aaa | ||
|
|
b0dc538119 | ||
|
|
27c9d2a02c | ||
|
|
87e0d0004d | ||
|
|
dba0fb7b33 | ||
|
|
72be651ca8 | ||
|
|
db2bf3ea06 | ||
|
|
e87380775f | ||
|
|
58ba01f20f | ||
|
|
59332dc47d | ||
|
|
f34b8fbc6b | ||
|
|
79525af42e | ||
|
|
69e93d4b8c | ||
|
|
810f372d1c | ||
|
|
453705a4e1 | ||
|
|
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 |
215
apps/broker/src/audit.ts
Normal file
215
apps/broker/src/audit.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Signed audit log with hash-chain integrity.
|
||||
*
|
||||
* Every significant mesh event is recorded as an append-only entry.
|
||||
* Each entry's SHA-256 hash includes the previous entry's hash,
|
||||
* forming a tamper-evident chain per mesh. If any row is modified
|
||||
* or deleted, all subsequent hashes will fail verification.
|
||||
*
|
||||
* NEVER logs message content (ciphertext or plaintext) — only metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { asc, desc, eq, sql, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { auditLog } from "@turbostarter/db/schema/mesh";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lastHash = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core audit logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeHash(
|
||||
prevHash: string,
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
createdAt: Date,
|
||||
): string {
|
||||
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an audit entry for a mesh event.
|
||||
*
|
||||
* Fire-and-forget safe — callers should `void audit(...)` or
|
||||
* `.catch(log.warn)` to avoid blocking the hot path.
|
||||
*/
|
||||
export async function audit(
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
actorDisplayName: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const prevHash = lastHash.get(meshId) ?? "genesis";
|
||||
const createdAt = new Date();
|
||||
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
|
||||
|
||||
try {
|
||||
await db.insert(auditLog).values({
|
||||
meshId,
|
||||
eventType,
|
||||
actorMemberId,
|
||||
actorDisplayName,
|
||||
payload,
|
||||
prevHash,
|
||||
hash,
|
||||
createdAt,
|
||||
});
|
||||
lastHash.set(meshId, hash);
|
||||
} catch (e) {
|
||||
log.warn("audit log insert failed", {
|
||||
mesh_id: meshId,
|
||||
event_type: eventType,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup: load last hash per mesh from DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadLastHashes(): Promise<void> {
|
||||
try {
|
||||
// For each mesh, find the most recent audit entry by id (serial).
|
||||
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
|
||||
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
|
||||
SELECT DISTINCT ON (mesh_id) mesh_id, hash
|
||||
FROM mesh.audit_log
|
||||
ORDER BY mesh_id, id DESC
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
lastHash.set(row.mesh_id, row.hash);
|
||||
}
|
||||
log.info("audit: loaded last hashes", { meshes: lastHash.size });
|
||||
} catch (e) {
|
||||
// Table may not exist yet on first boot — that's fine.
|
||||
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function verifyChain(
|
||||
meshId: string,
|
||||
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.meshId, meshId))
|
||||
.orderBy(asc(auditLog.id));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: true, entries: 0 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
|
||||
|
||||
// Verify prevHash linkage
|
||||
if (row.prevHash !== expectedPrevHash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
|
||||
// Recompute hash and verify
|
||||
const recomputed = computeHash(
|
||||
row.prevHash,
|
||||
row.meshId,
|
||||
row.eventType,
|
||||
row.actorMemberId,
|
||||
row.payload as Record<string, unknown>,
|
||||
row.createdAt,
|
||||
);
|
||||
if (recomputed !== row.hash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, entries: rows.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: paginated audit entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAuditLog(
|
||||
meshId: string,
|
||||
options?: { limit?: number; offset?: number; eventType?: string },
|
||||
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const conditions = [eq(auditLog.meshId, meshId)];
|
||||
if (options?.eventType) {
|
||||
conditions.push(eq(auditLog.eventType, options.eventType));
|
||||
}
|
||||
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
|
||||
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(auditLog.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLog)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
eventType: r.eventType,
|
||||
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
|
||||
payload: r.payload as Record<string, unknown>,
|
||||
hash: r.hash,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure table exists (raw DDL for first-boot before migrations run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureAuditLogTable(): Promise<void> {
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS mesh.audit_log (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_member_id TEXT,
|
||||
actor_display_name TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log.warn("audit: ensureAuditLogTable failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
mesh,
|
||||
meshFile,
|
||||
meshFileAccess,
|
||||
meshFileKey,
|
||||
meshContext,
|
||||
meshMember as memberTable,
|
||||
meshMemory,
|
||||
meshState,
|
||||
meshSkill,
|
||||
meshStream,
|
||||
meshTask,
|
||||
messageQueue,
|
||||
@@ -395,6 +397,7 @@ export async function listPeersInMesh(
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
connectedAt: Date;
|
||||
}>
|
||||
> {
|
||||
@@ -408,6 +411,7 @@ export async function listPeersInMesh(
|
||||
summary: presence.summary,
|
||||
groups: presence.groups,
|
||||
sessionId: presence.sessionId,
|
||||
cwd: presence.cwd,
|
||||
connectedAt: presence.connectedAt,
|
||||
})
|
||||
.from(presence)
|
||||
@@ -427,6 +431,7 @@ export async function listPeersInMesh(
|
||||
summary: r.summary,
|
||||
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
|
||||
sessionId: r.sessionId,
|
||||
cwd: r.cwd,
|
||||
connectedAt: r.connectedAt,
|
||||
}));
|
||||
}
|
||||
@@ -700,6 +705,176 @@ export async function forgetMemory(
|
||||
);
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
|
||||
/**
|
||||
* Upsert a skill in a mesh. If a skill with the same name exists, it is updated.
|
||||
*/
|
||||
export async function shareSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
instructions: string,
|
||||
tags: string[],
|
||||
memberId?: string,
|
||||
memberName?: string,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
.select({ id: meshSkill.id })
|
||||
.from(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(meshSkill)
|
||||
.set({
|
||||
description,
|
||||
instructions,
|
||||
tags,
|
||||
authorMemberId: memberId ?? null,
|
||||
authorName: memberName ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meshSkill.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(meshSkill)
|
||||
.values({
|
||||
meshId,
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
tags,
|
||||
authorMemberId: memberId ?? null,
|
||||
authorName: memberName ?? null,
|
||||
})
|
||||
.returning({ id: meshSkill.id });
|
||||
if (!row) throw new Error("failed to insert skill");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a skill by name in a mesh.
|
||||
*/
|
||||
export async function getSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
} | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
instructions: meshSkill.instructions,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0]!;
|
||||
return {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
instructions: r.instructions,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List skills in a mesh, optionally filtering by keyword across name, description, and tags.
|
||||
*/
|
||||
export async function listSkills(
|
||||
meshId: string,
|
||||
query?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(
|
||||
and(
|
||||
eq(meshSkill.meshId, meshId),
|
||||
or(
|
||||
sql`${meshSkill.name} ILIKE ${pattern}`,
|
||||
sql`${meshSkill.description} ILIKE ${pattern}`,
|
||||
sql`EXISTS (SELECT 1 FROM unnest(${meshSkill.tags}) AS t WHERE t ILIKE ${pattern})`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(eq(meshSkill.meshId, meshId))
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a skill by name in a mesh. Returns true if a row was deleted.
|
||||
*/
|
||||
export async function removeSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.returning({ id: meshSkill.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// --- File sharing ---
|
||||
|
||||
/**
|
||||
@@ -717,6 +892,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember?: string;
|
||||
targetSpec?: string;
|
||||
expiresAt?: Date;
|
||||
encrypted?: boolean;
|
||||
ownerPubkey?: string;
|
||||
}): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshFile)
|
||||
@@ -732,6 +909,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember: args.uploadedByMember ?? null,
|
||||
targetSpec: args.targetSpec ?? null,
|
||||
expiresAt: args.expiresAt ?? null,
|
||||
encrypted: args.encrypted ?? false,
|
||||
ownerPubkey: args.ownerPubkey ?? null,
|
||||
})
|
||||
.returning({ id: meshFile.id });
|
||||
if (!row) throw new Error("failed to insert file row");
|
||||
@@ -755,6 +934,8 @@ export async function getFile(
|
||||
uploadedByName: string | null;
|
||||
targetSpec: string | null;
|
||||
uploadedAt: Date;
|
||||
encrypted: boolean;
|
||||
ownerPubkey: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
@@ -768,6 +949,8 @@ export async function getFile(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
targetSpec: meshFile.targetSpec,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
encrypted: meshFile.encrypted,
|
||||
ownerPubkey: meshFile.ownerPubkey,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(
|
||||
@@ -782,6 +965,8 @@ export async function getFile(
|
||||
return {
|
||||
...row,
|
||||
tags: (row.tags ?? []) as string[],
|
||||
encrypted: row.encrypted,
|
||||
ownerPubkey: row.ownerPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -801,6 +986,7 @@ export async function listFiles(
|
||||
uploadedBy: string;
|
||||
uploadedAt: Date;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>
|
||||
> {
|
||||
const conditions = [
|
||||
@@ -822,6 +1008,7 @@ export async function listFiles(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
persistent: meshFile.persistent,
|
||||
encrypted: meshFile.encrypted,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(and(...conditions))
|
||||
@@ -835,6 +1022,7 @@ export async function listFiles(
|
||||
uploadedBy: r.uploadedByName ?? "unknown",
|
||||
uploadedAt: r.uploadedAt,
|
||||
persistent: r.persistent,
|
||||
encrypted: r.encrypted,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -892,11 +1080,62 @@ export async function deleteFile(
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert encrypted key blobs for a newly uploaded E2E file. */
|
||||
export async function insertFileKeys(
|
||||
fileId: string,
|
||||
keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>,
|
||||
): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await db.insert(meshFileKey).values(
|
||||
keys.map((k) => ({
|
||||
fileId,
|
||||
peerPubkey: k.peerPubkey,
|
||||
sealedKey: k.sealedKey,
|
||||
grantedByPubkey: k.grantedByPubkey ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the sealed key for a specific peer, or null if not authorized. */
|
||||
export async function getFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ sealedKey: meshFileKey.sealedKey })
|
||||
.from(meshFileKey)
|
||||
.where(
|
||||
and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)),
|
||||
);
|
||||
return row?.sealedKey ?? null;
|
||||
}
|
||||
|
||||
/** Grant a peer access to an encrypted file (upsert their key blob). */
|
||||
export async function grantFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
sealedKey: string,
|
||||
grantedByPubkey: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(meshFileKey)
|
||||
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
|
||||
.onConflictDoUpdate({
|
||||
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
|
||||
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Context sharing ---
|
||||
|
||||
/**
|
||||
* Upsert a context snapshot for a peer. Each (meshId, presenceId) pair
|
||||
* has at most one context row — repeated calls update it in place.
|
||||
* Upsert a context snapshot for a peer. When `memberId` is provided the
|
||||
* row is keyed on (meshId, memberId) — a stable identifier that survives
|
||||
* reconnects. This prevents stale rows from accumulating every time a
|
||||
* session reconnects with a fresh ephemeral presenceId.
|
||||
*
|
||||
* Falls back to (meshId, presenceId) lookup when memberId is absent
|
||||
* (e.g. legacy callers or anonymous connections).
|
||||
*/
|
||||
export async function shareContext(
|
||||
meshId: string,
|
||||
@@ -906,24 +1145,27 @@ export async function shareContext(
|
||||
filesRead?: string[],
|
||||
keyFindings?: string[],
|
||||
tags?: string[],
|
||||
memberId?: string,
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
// Try to find existing context for this presence in this mesh.
|
||||
|
||||
// Build the WHERE clause: prefer stable memberId, fall back to presenceId.
|
||||
const lookupWhere = memberId
|
||||
? and(eq(meshContext.meshId, meshId), eq(meshContext.memberId, memberId))
|
||||
: and(eq(meshContext.meshId, meshId), eq(meshContext.presenceId, presenceId));
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: meshContext.id })
|
||||
.from(meshContext)
|
||||
.where(
|
||||
and(
|
||||
eq(meshContext.meshId, meshId),
|
||||
eq(meshContext.presenceId, presenceId),
|
||||
),
|
||||
)
|
||||
.where(lookupWhere)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(meshContext)
|
||||
.set({
|
||||
// Keep presenceId current so it reflects the latest connection.
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
filesRead: filesRead ?? [],
|
||||
@@ -939,6 +1181,7 @@ export async function shareContext(
|
||||
.insert(meshContext)
|
||||
.values({
|
||||
meshId,
|
||||
memberId: memberId ?? null,
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
@@ -1188,16 +1431,22 @@ export async function createStream(
|
||||
name: string,
|
||||
createdByName: string,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
// Atomic upsert: INSERT ... ON CONFLICT DO NOTHING to avoid TOCTOU race
|
||||
// when two callers concurrently attempt to create the same stream.
|
||||
const [inserted] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: meshStream.id });
|
||||
|
||||
if (inserted) return inserted.id;
|
||||
|
||||
// Row already existed — fetch the id.
|
||||
const [existing] = await db
|
||||
.select({ id: meshStream.id })
|
||||
.from(meshStream)
|
||||
.where(and(eq(meshStream.meshId, meshId), eq(meshStream.name, name)));
|
||||
if (existing.length > 0) return existing[0]!.id;
|
||||
const [row] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.returning({ id: meshStream.id });
|
||||
return row!.id;
|
||||
return existing!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,11 +1551,28 @@ export async function drainForMember(
|
||||
);
|
||||
|
||||
// Build group target matching: @all (broadcast alias) + @<groupname>
|
||||
// for each group the peer belongs to.
|
||||
// for each group the peer belongs to, expanded to all ancestor paths.
|
||||
//
|
||||
// Hierarchical routing (downward propagation):
|
||||
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
|
||||
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
|
||||
// This lets leads send to a parent group and reach all sub-teams.
|
||||
//
|
||||
// Resolution happens at drain time (pull model) — no duplicates stored,
|
||||
// no schema changes, fully backward-compatible.
|
||||
const groupTargets = ["@all"];
|
||||
if (memberGroups) {
|
||||
const seen = new Set<string>();
|
||||
for (const g of memberGroups) {
|
||||
groupTargets.push(`@${g}`);
|
||||
const parts = g.split("/");
|
||||
// Add the group itself + every ancestor prefix.
|
||||
for (let depth = parts.length; depth > 0; depth--) {
|
||||
const ancestor = parts.slice(0, depth).join("/");
|
||||
if (!seen.has(ancestor)) {
|
||||
seen.add(ancestor);
|
||||
groupTargets.push(`@${ancestor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupTargetList = sql.raw(
|
||||
@@ -1337,7 +1603,7 @@ export async function drainForMember(
|
||||
AND delivered_at IS NULL
|
||||
AND priority::text IN (${priorityList})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
||||
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ const envSchema = z.object({
|
||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.coerce.boolean().default(false),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,14 @@ export interface WSHelloMessage {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
/** OS hostname — used to detect same-machine peers for direct file access. */
|
||||
hostname?: string;
|
||||
/** Peer type: ai session, human user, or external connector. */
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
/** Channel the peer connected from (e.g. "claude-code", "telegram", "slack", "web"). */
|
||||
channel?: string;
|
||||
/** AI model identifier (e.g. "opus-4", "sonnet-4"). */
|
||||
model?: string;
|
||||
/** Initial groups to join on connect. */
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||
@@ -86,6 +94,13 @@ export interface WSPushMessage {
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
/** Optional semantic tag — "reminder" when delivered by the scheduler,
|
||||
* "system" for broker-originated topology events (peer join/leave). */
|
||||
subtype?: "reminder" | "system";
|
||||
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||
event?: string;
|
||||
/** Structured payload for the event. */
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Client → broker: manual status override (dnd, forced idle). */
|
||||
@@ -105,6 +120,36 @@ export interface WSSetSummaryMessage {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
|
||||
/** Client → broker: toggle visibility in the mesh. */
|
||||
export interface WSSetVisibleMessage {
|
||||
type: "set_visible";
|
||||
visible: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: set public profile metadata. */
|
||||
export interface WSSetProfileMessage {
|
||||
type: "set_profile";
|
||||
avatar?: string; // emoji or URL
|
||||
title?: string; // short role label
|
||||
bio?: string; // one-liner
|
||||
capabilities?: string[]; // what I can help with
|
||||
_reqId?: string;
|
||||
}
|
||||
/** Client → broker: self-report resource usage stats. */
|
||||
export interface WSSetStatsMessage {
|
||||
type: "set_stats";
|
||||
stats: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number; // seconds since session start
|
||||
errors?: number;
|
||||
};
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: join a group with optional role. */
|
||||
export interface WSJoinGroupMessage {
|
||||
type: "join_group";
|
||||
@@ -161,6 +206,7 @@ export interface WSAckMessage {
|
||||
id: string; // echoes client-side correlation id
|
||||
messageId: string;
|
||||
queued: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: hello handshake acknowledgement. */
|
||||
@@ -168,6 +214,16 @@ export interface WSHelloAckMessage {
|
||||
type: "hello_ack";
|
||||
presenceId: string;
|
||||
memberDisplayName: string;
|
||||
/** True when the broker restored persisted state from a previous session. */
|
||||
restored?: boolean;
|
||||
/** Last summary set before disconnect (only when restored). */
|
||||
lastSummary?: string;
|
||||
/** ISO timestamp of last disconnect (only when restored). */
|
||||
lastSeenAt?: string;
|
||||
/** Restored groups from previous session (only when restored and hello had no groups). */
|
||||
restoredGroups?: Array<{ name: string; role?: string }>;
|
||||
/** Restored cumulative stats (only when restored). */
|
||||
restoredStats?: { messagesIn: number; messagesOut: number; toolCalls: number; errors: number };
|
||||
}
|
||||
|
||||
/** Broker → client: list of connected peers in the same mesh. */
|
||||
@@ -181,7 +237,27 @@ export interface WSPeersListMessage {
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
cwd?: string;
|
||||
hostname?: string;
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
channel?: string;
|
||||
model?: string;
|
||||
stats?: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number;
|
||||
errors?: number;
|
||||
};
|
||||
visible?: boolean;
|
||||
profile?: {
|
||||
avatar?: string;
|
||||
title?: string;
|
||||
bio?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: a state key was changed by another peer. */
|
||||
@@ -199,6 +275,7 @@ export interface WSStateResultMessage {
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_state. */
|
||||
@@ -210,12 +287,14 @@ export interface WSStateListMessage {
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for a remember. */
|
||||
export interface WSMemoryStoredMessage {
|
||||
type: "memory_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to recall. */
|
||||
@@ -228,6 +307,7 @@ export interface WSMemoryResultsMessage {
|
||||
rememberedBy: string;
|
||||
rememberedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Vector storage messages ---
|
||||
@@ -295,6 +375,13 @@ export interface WSMeshSchemaMessage {
|
||||
|
||||
// --- Vector/Graph response messages ---
|
||||
|
||||
/** Broker → client: confirmation that a vector point was stored. */
|
||||
export interface WSVectorStoredMessage {
|
||||
type: "vector_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: vector search results. */
|
||||
export interface WSVectorResultsMessage {
|
||||
type: "vector_results";
|
||||
@@ -304,18 +391,21 @@ export interface WSVectorResultsMessage {
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of vector collections. */
|
||||
export interface WSCollectionListMessage {
|
||||
type: "collection_list";
|
||||
collections: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: graph query results. */
|
||||
export interface WSGraphResultMessage {
|
||||
type: "graph_result";
|
||||
records: Array<Record<string, unknown>>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh SQL query results. */
|
||||
@@ -324,6 +414,7 @@ export interface WSMeshQueryResultMessage {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
rowCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh schema introspection results. */
|
||||
@@ -333,6 +424,7 @@ export interface WSMeshSchemaResultMessage {
|
||||
name: string;
|
||||
columns: Array<{ name: string; type: string; nullable: boolean }>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get full mesh overview. */
|
||||
@@ -355,6 +447,7 @@ export interface WSMeshInfoResultMessage {
|
||||
collections: string[];
|
||||
yourName: string;
|
||||
yourGroups: Array<{ name: string; role?: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: check delivery status of a message. */
|
||||
@@ -375,6 +468,7 @@ export interface WSMessageStatusResultMessage {
|
||||
pubkey: string;
|
||||
status: "delivered" | "held" | "disconnected";
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- File sharing messages ---
|
||||
@@ -404,12 +498,23 @@ export interface WSDeleteFileMessage {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/** Client → broker: grant a peer access to an encrypted file. */
|
||||
export interface WSGrantFileAccessMessage {
|
||||
type: "grant_file_access";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
sealedKey: string;
|
||||
}
|
||||
|
||||
/** Broker → client: presigned URL for downloading a file. */
|
||||
export interface WSFileUrlMessage {
|
||||
type: "file_url";
|
||||
fileId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
encrypted?: boolean;
|
||||
sealedKey?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of files in the mesh. */
|
||||
@@ -423,7 +528,17 @@ export interface WSFileListMessage {
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for grant_file_access. */
|
||||
export interface WSGrantFileAccessOkMessage {
|
||||
type: "grant_file_access_ok";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: access log for a file. */
|
||||
@@ -434,6 +549,7 @@ export interface WSFileStatusResultMessage {
|
||||
peerName: string;
|
||||
accessedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Context sharing messages ---
|
||||
@@ -475,6 +591,7 @@ export interface WSContextResultsMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_contexts. */
|
||||
@@ -486,6 +603,7 @@ export interface WSContextListMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Task messages ---
|
||||
@@ -523,6 +641,7 @@ export interface WSListTasksMessage {
|
||||
export interface WSTaskCreatedMessage {
|
||||
type: "task_created";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_tasks, claim_task, complete_task. */
|
||||
@@ -539,6 +658,7 @@ export interface WSTaskListMessage {
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Stream messages ---
|
||||
@@ -578,6 +698,7 @@ export interface WSStreamCreatedMessage {
|
||||
type: "stream_created";
|
||||
id: string;
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: real-time data pushed from a stream. */
|
||||
@@ -588,6 +709,13 @@ export interface WSStreamDataMessage {
|
||||
publishedBy: string;
|
||||
}
|
||||
|
||||
/** Broker → client: confirmation that a stream subscription was registered. */
|
||||
export interface WSSubscribedMessage {
|
||||
type: "subscribed";
|
||||
stream: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_streams. */
|
||||
export interface WSStreamListMessage {
|
||||
type: "stream_list";
|
||||
@@ -598,6 +726,199 @@ export interface WSStreamListMessage {
|
||||
createdAt: string;
|
||||
subscriberCount: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- MCP proxy messages ---
|
||||
|
||||
/** Client → broker: register an MCP server with the mesh. */
|
||||
export interface WSMcpRegisterMessage {
|
||||
type: "mcp_register";
|
||||
serverName: string;
|
||||
description: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: unregister an MCP server. */
|
||||
export interface WSMcpUnregisterMessage {
|
||||
type: "mcp_unregister";
|
||||
serverName: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list all MCP servers in the mesh. */
|
||||
export interface WSMcpListMessage {
|
||||
type: "mcp_list";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: call a tool on a mesh-registered MCP server. */
|
||||
export interface WSMcpCallMessage {
|
||||
type: "mcp_call";
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: response to a forwarded MCP call. */
|
||||
export interface WSMcpCallResponseMessage {
|
||||
type: "mcp_call_response";
|
||||
callId: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for mcp_register. */
|
||||
export interface WSMcpRegisterAckMessage {
|
||||
type: "mcp_register_ack";
|
||||
serverName: string;
|
||||
toolCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of MCP servers in the mesh. */
|
||||
export interface WSMcpListResultMessage {
|
||||
type: "mcp_list_result";
|
||||
servers: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
hostedBy: string;
|
||||
tools: Array<{ name: string; description: string }>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of an MCP tool call. */
|
||||
export interface WSMcpCallResultMessage {
|
||||
type: "mcp_call_result";
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: forwarded MCP tool call to execute locally. */
|
||||
export interface WSMcpCallForwardMessage {
|
||||
type: "mcp_call_forward";
|
||||
callId: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
callerName: string;
|
||||
}
|
||||
|
||||
// --- Webhook CRUD messages ---
|
||||
|
||||
/** Client → broker: create an inbound webhook. */
|
||||
export interface WSCreateWebhookMessage {
|
||||
type: "create_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list webhooks for the mesh. */
|
||||
export interface WSListWebhooksMessage {
|
||||
type: "list_webhooks";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deactivate a webhook. */
|
||||
export interface WSDeleteWebhookMessage {
|
||||
type: "delete_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for create_webhook. */
|
||||
export interface WSWebhookAckMessage {
|
||||
type: "webhook_ack";
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of webhooks for the mesh. */
|
||||
export interface WSWebhookListMessage {
|
||||
type: "webhook_list";
|
||||
webhooks: Array<{ name: string; url: string; active: boolean; createdAt: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Peer file sharing (relay) messages ---
|
||||
|
||||
/** Client → broker: request a file from a peer's local filesystem. */
|
||||
export interface WSPeerFileRequestMessage {
|
||||
type: "peer_file_request";
|
||||
targetPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded file request from another peer. */
|
||||
export interface WSPeerFileRequestForwardMessage {
|
||||
type: "peer_file_request_forward";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: response with file content (or error). */
|
||||
export interface WSPeerFileResponseMessage {
|
||||
type: "peer_file_response";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
content?: string; // base64 encoded
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded file content from target peer. */
|
||||
export interface WSPeerFileResponseForwardMessage {
|
||||
type: "peer_file_response_forward";
|
||||
filePath: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: request a directory listing from a peer. */
|
||||
export interface WSPeerDirRequestMessage {
|
||||
type: "peer_dir_request";
|
||||
targetPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded directory listing request. */
|
||||
export interface WSPeerDirRequestForwardMessage {
|
||||
type: "peer_dir_request_forward";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: directory listing response. */
|
||||
export interface WSPeerDirResponseMessage {
|
||||
type: "peer_dir_response";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded directory listing from target peer. */
|
||||
export interface WSPeerDirResponseForwardMessage {
|
||||
type: "peer_dir_response_forward";
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: structured error. */
|
||||
@@ -606,6 +927,152 @@ export interface WSErrorMessage {
|
||||
code: string;
|
||||
message: string;
|
||||
id?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Audit log messages ---
|
||||
|
||||
/** Client → broker: query paginated audit entries for a mesh. */
|
||||
export interface WSAuditQueryMessage {
|
||||
type: "audit_query";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
eventType?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: verify the hash chain for the mesh audit log. */
|
||||
export interface WSAuditVerifyMessage {
|
||||
type: "audit_verify";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: paginated audit log entries. */
|
||||
export interface WSAuditResultMessage {
|
||||
type: "audit_result";
|
||||
entries: Array<{
|
||||
id: number;
|
||||
eventType: string;
|
||||
actor: string;
|
||||
payload: Record<string, unknown>;
|
||||
hash: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of hash chain verification. */
|
||||
export interface WSAuditVerifyResultMessage {
|
||||
type: "audit_verify_result";
|
||||
valid: boolean;
|
||||
entries: number;
|
||||
brokenAt?: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Simulation clock messages ---
|
||||
|
||||
/** Client → broker: set the simulation clock speed. */
|
||||
export interface WSSetClockMessage {
|
||||
type: "set_clock";
|
||||
speed: number; // multiplier: 1, 2, 5, 10, 50, 100
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: pause the simulation clock. */
|
||||
export interface WSPauseClockMessage {
|
||||
type: "pause_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: resume a paused simulation clock. */
|
||||
export interface WSResumeClockMessage {
|
||||
type: "resume_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get current clock status. */
|
||||
export interface WSGetClockMessage {
|
||||
type: "get_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: current simulation clock status. */
|
||||
export interface WSClockStatusMessage {
|
||||
type: "clock_status";
|
||||
speed: number;
|
||||
paused: boolean;
|
||||
tick: number;
|
||||
simTime: string; // ISO timestamp
|
||||
startedAt: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
|
||||
/** Client → broker: schedule a message for future delivery. */
|
||||
export interface WSScheduleMessage {
|
||||
type: "schedule";
|
||||
to: string;
|
||||
message: string;
|
||||
/** Unix timestamp (ms) when to deliver. Ignored for cron schedules. */
|
||||
deliverAt: number;
|
||||
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
|
||||
subtype?: "reminder";
|
||||
/** Standard 5-field cron expression for recurring delivery. */
|
||||
cron?: string;
|
||||
/** Whether this is a recurring schedule. Implied true when `cron` is set. */
|
||||
recurring?: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list pending scheduled messages for this member. */
|
||||
export interface WSListScheduledMessage {
|
||||
type: "list_scheduled";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: cancel a scheduled message by id. */
|
||||
export interface WSCancelScheduledMessage {
|
||||
type: "cancel_scheduled";
|
||||
scheduledId: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for schedule, carries the assigned id. */
|
||||
export interface WSScheduledAckMessage {
|
||||
type: "scheduled_ack";
|
||||
scheduledId: string;
|
||||
deliverAt: number;
|
||||
/** Present for cron schedules — echoes the expression. */
|
||||
cron?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of pending scheduled messages. */
|
||||
export interface WSScheduledListMessage {
|
||||
type: "scheduled_list";
|
||||
messages: Array<{
|
||||
id: string;
|
||||
to: string;
|
||||
message: string;
|
||||
deliverAt: number;
|
||||
createdAt: number;
|
||||
/** Present for cron/recurring entries. */
|
||||
cron?: string;
|
||||
/** Number of times the cron entry has fired so far. */
|
||||
firedCount?: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: cancel confirmation. */
|
||||
export interface WSCancelScheduledAckMessage {
|
||||
type: "cancel_scheduled_ack";
|
||||
scheduledId: string;
|
||||
ok: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export type WSClientMessage =
|
||||
@@ -614,6 +1081,8 @@ export type WSClientMessage =
|
||||
| WSSetStatusMessage
|
||||
| WSListPeersMessage
|
||||
| WSSetSummaryMessage
|
||||
| WSSetVisibleMessage
|
||||
| WSSetProfileMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage
|
||||
| WSSetStateMessage
|
||||
@@ -627,6 +1096,7 @@ export type WSClientMessage =
|
||||
| WSListFilesMessage
|
||||
| WSFileStatusMessage
|
||||
| WSDeleteFileMessage
|
||||
| WSGrantFileAccessMessage
|
||||
| WSShareContextMessage
|
||||
| WSGetContextMessage
|
||||
| WSListContextsMessage
|
||||
@@ -648,7 +1118,101 @@ export type WSClientMessage =
|
||||
| WSSubscribeMessage
|
||||
| WSUnsubscribeMessage
|
||||
| WSListStreamsMessage
|
||||
| WSMeshInfoMessage;
|
||||
| WSMeshInfoMessage
|
||||
| WSSetClockMessage
|
||||
| WSPauseClockMessage
|
||||
| WSResumeClockMessage
|
||||
| WSGetClockMessage
|
||||
| WSScheduleMessage
|
||||
| WSListScheduledMessage
|
||||
| WSCancelScheduledMessage
|
||||
| WSMcpRegisterMessage
|
||||
| WSMcpUnregisterMessage
|
||||
| WSMcpListMessage
|
||||
| WSMcpCallMessage
|
||||
| WSMcpCallResponseMessage
|
||||
| WSShareSkillMessage
|
||||
| WSGetSkillMessage
|
||||
| WSListSkillsMessage
|
||||
| WSRemoveSkillMessage
|
||||
| WSSetStatsMessage
|
||||
| WSCreateWebhookMessage
|
||||
| WSListWebhooksMessage
|
||||
| WSDeleteWebhookMessage
|
||||
| WSPeerFileRequestMessage
|
||||
| WSPeerFileResponseMessage
|
||||
| WSPeerDirRequestMessage
|
||||
| WSPeerDirResponseMessage
|
||||
| WSAuditQueryMessage
|
||||
| WSAuditVerifyMessage;
|
||||
|
||||
// --- Skill messages ---
|
||||
|
||||
/** Client → broker: publish or update a skill. */
|
||||
export interface WSShareSkillMessage {
|
||||
type: "share_skill";
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags?: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: load a skill by name. */
|
||||
export interface WSGetSkillMessage {
|
||||
type: "get_skill";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list skills, optionally filtered by keyword. */
|
||||
export interface WSListSkillsMessage {
|
||||
type: "list_skills";
|
||||
query?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: remove a skill by name. */
|
||||
export interface WSRemoveSkillMessage {
|
||||
type: "remove_skill";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for share_skill or remove_skill. */
|
||||
export interface WSSkillAckMessage {
|
||||
type: "skill_ack";
|
||||
name: string;
|
||||
action: "shared" | "removed" | "not_found";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to get_skill with full skill data. */
|
||||
export interface WSSkillDataMessage {
|
||||
type: "skill_data";
|
||||
skill: {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_skills. */
|
||||
export interface WSSkillListMessage {
|
||||
type: "skill_list";
|
||||
skills: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
@@ -664,11 +1228,13 @@ export type WSServerMessage =
|
||||
| WSFileUrlMessage
|
||||
| WSFileListMessage
|
||||
| WSFileStatusResultMessage
|
||||
| WSGrantFileAccessOkMessage
|
||||
| WSContextSharedMessage
|
||||
| WSContextResultsMessage
|
||||
| WSContextListMessage
|
||||
| WSTaskCreatedMessage
|
||||
| WSTaskListMessage
|
||||
| WSVectorStoredMessage
|
||||
| WSVectorResultsMessage
|
||||
| WSCollectionListMessage
|
||||
| WSGraphResultMessage
|
||||
@@ -676,6 +1242,26 @@ export type WSServerMessage =
|
||||
| WSMeshSchemaResultMessage
|
||||
| WSStreamCreatedMessage
|
||||
| WSStreamDataMessage
|
||||
| WSSubscribedMessage
|
||||
| WSStreamListMessage
|
||||
| WSMeshInfoResultMessage
|
||||
| WSScheduledAckMessage
|
||||
| WSScheduledListMessage
|
||||
| WSCancelScheduledAckMessage
|
||||
| WSMcpRegisterAckMessage
|
||||
| WSMcpListResultMessage
|
||||
| WSMcpCallResultMessage
|
||||
| WSMcpCallForwardMessage
|
||||
| WSClockStatusMessage
|
||||
| WSSkillAckMessage
|
||||
| WSSkillDataMessage
|
||||
| WSSkillListMessage
|
||||
| WSWebhookAckMessage
|
||||
| WSWebhookListMessage
|
||||
| WSPeerFileRequestForwardMessage
|
||||
| WSPeerFileResponseForwardMessage
|
||||
| WSPeerDirRequestForwardMessage
|
||||
| WSPeerDirResponseForwardMessage
|
||||
| WSAuditResultMessage
|
||||
| WSAuditVerifyResultMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
97
apps/broker/src/webhooks.ts
Normal file
97
apps/broker/src/webhooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Inbound webhook handler.
|
||||
*
|
||||
* External services POST JSON to `/hook/:meshId/:secret`. The broker
|
||||
* verifies the secret against the mesh.webhook table, then pushes the
|
||||
* payload to all connected peers in that mesh as a "webhook" push.
|
||||
*/
|
||||
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshWebhook } from "@turbostarter/db/schema/mesh";
|
||||
import type { WSPushMessage } from "./types";
|
||||
import { log } from "./logger";
|
||||
|
||||
export interface WebhookResult {
|
||||
status: number;
|
||||
body: { ok: boolean; delivered?: number; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a webhook by meshId + secret, verify it's active, then return
|
||||
* the webhook name for push routing. Returns null if not found/inactive.
|
||||
*/
|
||||
async function findActiveWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
): Promise<{ id: string; name: string; meshId: string } | null> {
|
||||
const rows = await db
|
||||
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
|
||||
.from(meshWebhook)
|
||||
.where(
|
||||
and(
|
||||
eq(meshWebhook.meshId, meshId),
|
||||
eq(meshWebhook.secret, secret),
|
||||
eq(meshWebhook.active, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook HTTP request.
|
||||
*
|
||||
* @param meshId - mesh ID from the URL path
|
||||
* @param secret - webhook secret from the URL path
|
||||
* @param body - parsed JSON body from the request
|
||||
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
|
||||
* Returns the number of peers the message was delivered to.
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
body: unknown,
|
||||
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
|
||||
): Promise<WebhookResult> {
|
||||
try {
|
||||
const webhook = await findActiveWebhook(meshId, secret);
|
||||
if (!webhook) {
|
||||
log.warn("webhook auth failed", { mesh_id: meshId });
|
||||
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
|
||||
if (body === null || body === undefined || typeof body !== "object") {
|
||||
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
|
||||
}
|
||||
|
||||
const pushMsg: WSPushMessage = {
|
||||
type: "push",
|
||||
subtype: "webhook" as any,
|
||||
event: webhook.name,
|
||||
eventData: body as Record<string, unknown>,
|
||||
messageId: crypto.randomUUID(),
|
||||
meshId: webhook.meshId,
|
||||
senderPubkey: `webhook:${webhook.name}`,
|
||||
priority: "next",
|
||||
nonce: "",
|
||||
ciphertext: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
|
||||
|
||||
log.info("webhook delivered", {
|
||||
webhook_name: webhook.name,
|
||||
mesh_id: webhook.meshId,
|
||||
delivered,
|
||||
});
|
||||
|
||||
return { status: 200, body: { ok: true, delivered } };
|
||||
} catch (e) {
|
||||
log.error("webhook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return { status: 500, body: { ok: false, error: "internal error" } };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.5.9",
|
||||
"version": "0.7.0",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"citty": "0.2.2",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0",
|
||||
"zod": "4.1.13"
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
39
apps/cli/src/commands/create.ts
Normal file
39
apps/cli/src/commands/create.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `claudemesh create` — Create a new mesh with an optional template.
|
||||
* Lists available templates if --list-templates is passed.
|
||||
*/
|
||||
import { listTemplates, getTemplate } from "../templates/index.js";
|
||||
|
||||
export function runCreate(args: Record<string, unknown>): void {
|
||||
if (args["list-templates"]) {
|
||||
console.log("Available mesh templates:\n");
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`);
|
||||
console.log(` ${t.description}`);
|
||||
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
|
||||
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = args.template as string | undefined;
|
||||
if (templateName) {
|
||||
const template = getTemplate(templateName);
|
||||
if (!template) {
|
||||
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Template "${template.name}" loaded:`);
|
||||
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
|
||||
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
|
||||
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
|
||||
console.log();
|
||||
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
|
||||
// Future: wire into actual mesh creation API
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Usage: claudemesh create --template <name>");
|
||||
console.log(" claudemesh create --list-templates");
|
||||
}
|
||||
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)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -212,6 +213,92 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||
*/
|
||||
const CLAUDEMESH_TOOLS = [
|
||||
"mcp__claudemesh__cancel_scheduled",
|
||||
"mcp__claudemesh__check_messages",
|
||||
"mcp__claudemesh__claim_task",
|
||||
"mcp__claudemesh__complete_task",
|
||||
"mcp__claudemesh__create_stream",
|
||||
"mcp__claudemesh__create_task",
|
||||
"mcp__claudemesh__delete_file",
|
||||
"mcp__claudemesh__file_status",
|
||||
"mcp__claudemesh__forget",
|
||||
"mcp__claudemesh__get_context",
|
||||
"mcp__claudemesh__get_file",
|
||||
"mcp__claudemesh__get_state",
|
||||
"mcp__claudemesh__grant_file_access",
|
||||
"mcp__claudemesh__graph_execute",
|
||||
"mcp__claudemesh__graph_query",
|
||||
"mcp__claudemesh__join_group",
|
||||
"mcp__claudemesh__leave_group",
|
||||
"mcp__claudemesh__list_collections",
|
||||
"mcp__claudemesh__list_contexts",
|
||||
"mcp__claudemesh__list_files",
|
||||
"mcp__claudemesh__list_peers",
|
||||
"mcp__claudemesh__list_scheduled",
|
||||
"mcp__claudemesh__list_state",
|
||||
"mcp__claudemesh__list_streams",
|
||||
"mcp__claudemesh__list_tasks",
|
||||
"mcp__claudemesh__mesh_execute",
|
||||
"mcp__claudemesh__mesh_info",
|
||||
"mcp__claudemesh__mesh_query",
|
||||
"mcp__claudemesh__mesh_schema",
|
||||
"mcp__claudemesh__message_status",
|
||||
"mcp__claudemesh__ping_mesh",
|
||||
"mcp__claudemesh__publish",
|
||||
"mcp__claudemesh__recall",
|
||||
"mcp__claudemesh__remember",
|
||||
"mcp__claudemesh__schedule_reminder",
|
||||
"mcp__claudemesh__send_message",
|
||||
"mcp__claudemesh__set_state",
|
||||
"mcp__claudemesh__set_status",
|
||||
"mcp__claudemesh__set_summary",
|
||||
"mcp__claudemesh__share_context",
|
||||
"mcp__claudemesh__share_file",
|
||||
"mcp__claudemesh__subscribe",
|
||||
"mcp__claudemesh__vector_delete",
|
||||
"mcp__claudemesh__vector_search",
|
||||
"mcp__claudemesh__vector_store",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||
* Merges into any existing list — never overwrites other entries.
|
||||
* Returns which tools were added vs already present.
|
||||
*/
|
||||
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||
if (toAdd.length > 0) {
|
||||
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claudemesh tools from allowedTools.
|
||||
* Leaves all other entries intact. Returns count removed.
|
||||
*/
|
||||
function uninstallAllowedTools(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||
const kept = existing.filter((t) => !toolSet.has(t));
|
||||
const removed = existing.length - kept.length;
|
||||
if (removed > 0) {
|
||||
settings.allowedTools = kept;
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||
* idempotent on the command string. Returns counts for reporting.
|
||||
@@ -321,6 +408,26 @@ export function runInstall(args: string[] = []): void {
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
@@ -345,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = loadConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh join <invite-url>")}` +
|
||||
dim(" — join an existing mesh"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||
@@ -375,6 +505,20 @@ export function runUninstall(): void {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||
*
|
||||
* Flags are defined in index.ts (citty command) — that is the source of
|
||||
* truth. This file receives already-parsed flags and rawArgs.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
||||
* 2. If --join: run join flow first (accepts token or URL)
|
||||
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||
* 2. If --join: run join flow first
|
||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||
@@ -18,73 +21,17 @@ import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
|
||||
// --- Arg parsing ---
|
||||
|
||||
interface LaunchArgs {
|
||||
name: string | null;
|
||||
role: string | null;
|
||||
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
|
||||
joinLink: string | null;
|
||||
meshSlug: string | null;
|
||||
messageMode: "push" | "inbox" | "off" | null;
|
||||
quiet: boolean;
|
||||
skipPermConfirm: boolean;
|
||||
claudeArgs: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): LaunchArgs {
|
||||
const result: LaunchArgs = {
|
||||
name: null,
|
||||
role: null,
|
||||
groups: null,
|
||||
joinLink: null,
|
||||
meshSlug: null,
|
||||
messageMode: null,
|
||||
quiet: false,
|
||||
skipPermConfirm: false,
|
||||
claudeArgs: [],
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--name" && i + 1 < argv.length) {
|
||||
result.name = argv[++i]!;
|
||||
} else if (arg.startsWith("--name=")) {
|
||||
result.name = arg.slice("--name=".length);
|
||||
} else if (arg === "--role" && i + 1 < argv.length) {
|
||||
result.role = argv[++i]!;
|
||||
} else if (arg.startsWith("--role=")) {
|
||||
result.role = arg.slice("--role=".length);
|
||||
} else if (arg === "--groups" && i + 1 < argv.length) {
|
||||
result.groups = argv[++i]!;
|
||||
} else if (arg.startsWith("--groups=")) {
|
||||
result.groups = arg.slice("--groups=".length);
|
||||
} else if (arg === "--join" && i + 1 < argv.length) {
|
||||
result.joinLink = argv[++i]!;
|
||||
} else if (arg.startsWith("--join=")) {
|
||||
result.joinLink = arg.slice("--join=".length);
|
||||
} else if (arg === "--mesh" && i + 1 < argv.length) {
|
||||
result.meshSlug = argv[++i]!;
|
||||
} else if (arg.startsWith("--mesh=")) {
|
||||
result.meshSlug = arg.slice("--mesh=".length);
|
||||
} else if (arg === "--inbox") {
|
||||
result.messageMode = "inbox";
|
||||
} else if (arg === "--no-messages") {
|
||||
result.messageMode = "off";
|
||||
} else if (arg === "--quiet") {
|
||||
result.quiet = true;
|
||||
} else if (arg === "-y" || arg === "--yes") {
|
||||
result.skipPermConfirm = true;
|
||||
} else if (arg === "--") {
|
||||
result.claudeArgs.push(...argv.slice(i + 1));
|
||||
break;
|
||||
} else {
|
||||
result.claudeArgs.push(arg);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
name?: string;
|
||||
role?: string;
|
||||
groups?: string;
|
||||
join?: string;
|
||||
mesh?: string;
|
||||
"message-mode"?: string;
|
||||
"system-prompt"?: string;
|
||||
yes?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
// --- Interactive mesh picker ---
|
||||
@@ -151,12 +98,12 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
console.log(yellow(bold(" Autonomous mode")));
|
||||
console.log("");
|
||||
console.log(" Claude will send and receive peer messages without asking");
|
||||
console.log(" you first. Peers exchange text only — no file access,");
|
||||
console.log(" no tool calls, no code execution.");
|
||||
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||
console.log("");
|
||||
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
||||
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -206,8 +153,26 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
|
||||
|
||||
// --- Main ---
|
||||
|
||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
const args = parseArgs(extraArgs);
|
||||
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||
// Extract args that follow "--" — passed straight through to claude.
|
||||
const dashIdx = rawArgs.indexOf("--");
|
||||
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||
|
||||
// Normalise flags into the internal shape used below.
|
||||
const args = {
|
||||
name: flags.name ?? null,
|
||||
role: flags.role ?? null,
|
||||
groups: flags.groups ?? null,
|
||||
joinLink: flags.join ?? null,
|
||||
meshSlug: flags.mesh ?? null,
|
||||
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||
: null),
|
||||
systemPrompt: flags["system-prompt"] ?? null,
|
||||
quiet: flags.quiet ?? false,
|
||||
skipPermConfirm: flags.yes ?? false,
|
||||
claudeArgs: claudePassthrough,
|
||||
};
|
||||
|
||||
// 1. If --join, run join flow first.
|
||||
if (args.joinLink) {
|
||||
@@ -318,6 +283,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
version: 1,
|
||||
meshes: [mesh],
|
||||
displayName,
|
||||
...(role ? { role } : {}),
|
||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||
messageMode,
|
||||
};
|
||||
@@ -347,10 +313,15 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
filtered.push(args.claudeArgs[i]!);
|
||||
}
|
||||
// --dangerously-skip-permissions is only added when the user explicitly
|
||||
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||
// This keeps permissions tight for multi-person meshes.
|
||||
const claudeArgs = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
"--dangerously-skip-permissions",
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
|
||||
@@ -362,6 +333,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
...process.env,
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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("");
|
||||
}
|
||||
});
|
||||
}
|
||||
55
apps/cli/src/commands/peers.ts
Normal file
55
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
|
||||
if (cwdStr) console.log(` ${cwdStr}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
142
apps/cli/src/commands/remind.ts
Normal file
142
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
cron?: string; // 5-field cron expression for recurring
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseDuration(raw: string): number | null {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = (m[2] ?? "s").toLowerCase();
|
||||
if (unit.startsWith("d")) return n * 86_400_000;
|
||||
if (unit.startsWith("h")) return n * 3_600_000;
|
||||
if (unit.startsWith("m")) return n * 60_000;
|
||||
return n * 1_000;
|
||||
}
|
||||
|
||||
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
if (flags.in) {
|
||||
const ms = parseDuration(flags.in);
|
||||
if (ms === null) return null;
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
return isNaN(ts) ? null : ts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
targetSpec = flags.to;
|
||||
} else {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
} else {
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
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.
|
||||
*
|
||||
* Uses citty to define commands and flags. --help is generated from
|
||||
* the command definitions — the flag list here IS the documentation.
|
||||
*
|
||||
* Dispatches between two modes:
|
||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||
* - `claudemesh <subcommand>` → CLI subcommand
|
||||
*
|
||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
||||
*/
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { startMcpServer } from "./mcp/server";
|
||||
import { runInstall, runUninstall } from "./commands/install";
|
||||
import { runJoin } from "./commands/join";
|
||||
@@ -19,98 +21,268 @@ import { runLaunch } from "./commands/launch";
|
||||
import { runStatus } from "./commands/status";
|
||||
import { runDoctor } from "./commands/doctor";
|
||||
import { runWelcome } from "./commands/welcome";
|
||||
import { runPeers } from "./commands/peers";
|
||||
import { runSend } from "./commands/send";
|
||||
import { runInbox } from "./commands/inbox";
|
||||
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||
import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { runCreate } from "./commands/create";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
||||
|
||||
Usage:
|
||||
claudemesh <command> [args]
|
||||
|
||||
Commands:
|
||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
||||
(add --no-hooks for bare MCP registration)
|
||||
uninstall Remove MCP server + hooks
|
||||
launch [opts] Launch Claude Code with real-time push messages
|
||||
--name <name> Display name for this session
|
||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
||||
--join <url> Join a mesh before launching
|
||||
--quiet Skip the info banner
|
||||
-- <args> Pass remaining args to claude
|
||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||
list Show all joined meshes
|
||||
leave <slug> Leave a joined mesh
|
||||
status Health report: broker reachability per joined mesh
|
||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
||||
--help, -h Show this help
|
||||
--version, -v Show the CLI version
|
||||
|
||||
Environment:
|
||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
||||
`;
|
||||
|
||||
const cmd = process.argv[2];
|
||||
const args = process.argv.slice(3);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
switch (cmd) {
|
||||
case "mcp":
|
||||
await startMcpServer();
|
||||
return;
|
||||
case "install":
|
||||
runInstall(args);
|
||||
return;
|
||||
case "uninstall":
|
||||
runUninstall();
|
||||
return;
|
||||
case "hook":
|
||||
await runHook(args);
|
||||
return;
|
||||
case "launch":
|
||||
await runLaunch(args);
|
||||
return;
|
||||
case "join":
|
||||
await runJoin(args);
|
||||
return;
|
||||
case "list":
|
||||
runList();
|
||||
return;
|
||||
case "leave":
|
||||
runLeave(args);
|
||||
return;
|
||||
case "status":
|
||||
await runStatus();
|
||||
return;
|
||||
case "doctor":
|
||||
await runDoctor();
|
||||
return;
|
||||
case "seed-test-mesh":
|
||||
runSeedTestMesh(args);
|
||||
return;
|
||||
case "--version":
|
||||
case "-v":
|
||||
case "version":
|
||||
console.log(VERSION);
|
||||
return;
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "help":
|
||||
console.log(HELP);
|
||||
return;
|
||||
case undefined:
|
||||
runWelcome();
|
||||
return;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
console.error("Run `claudemesh --help` for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
process.exit(1);
|
||||
const launch = defineCommand({
|
||||
meta: {
|
||||
name: "launch",
|
||||
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Display name visible to other peers",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
|
||||
},
|
||||
groups: {
|
||||
type: "string",
|
||||
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
|
||||
},
|
||||
mesh: {
|
||||
type: "string",
|
||||
description: "Mesh slug (interactive picker if omitted and >1 joined)",
|
||||
},
|
||||
join: {
|
||||
type: "string",
|
||||
description: "Join a mesh via invite URL before launching",
|
||||
},
|
||||
"message-mode": {
|
||||
type: "string",
|
||||
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
|
||||
},
|
||||
"system-prompt": {
|
||||
type: "string",
|
||||
description: "Custom system prompt for this Claude session",
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "Skip the --dangerously-skip-permissions confirmation",
|
||||
default: false,
|
||||
},
|
||||
quiet: {
|
||||
type: "boolean",
|
||||
description: "Suppress banner and interactive prompts",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ args, rawArgs }) {
|
||||
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||
return runLaunch(args, rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const install = defineCommand({
|
||||
meta: {
|
||||
name: "install",
|
||||
description: "Register MCP server and status hooks with Claude Code",
|
||||
},
|
||||
args: {
|
||||
"no-hooks": {
|
||||
type: "boolean",
|
||||
description: "Register MCP server only, skip hooks",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ rawArgs }) {
|
||||
runInstall(rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const join = defineCommand({
|
||||
meta: {
|
||||
name: "join",
|
||||
description: "Join a mesh via invite URL or token",
|
||||
},
|
||||
args: {
|
||||
url: {
|
||||
type: "positional",
|
||||
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
return runJoin([args.url]);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = defineCommand({
|
||||
meta: {
|
||||
name: "leave",
|
||||
description: "Leave a joined mesh and remove its local keypair",
|
||||
},
|
||||
args: {
|
||||
slug: {
|
||||
type: "positional",
|
||||
description: "Mesh slug to leave (see `claudemesh list`)",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
runLeave([args.slug]);
|
||||
},
|
||||
});
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "claudemesh",
|
||||
version: VERSION,
|
||||
description: "Peer mesh for Claude Code sessions",
|
||||
},
|
||||
subCommands: {
|
||||
launch,
|
||||
create: defineCommand({
|
||||
meta: { name: "create", description: "Create a new mesh from a template" },
|
||||
args: {
|
||||
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
|
||||
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
|
||||
},
|
||||
run({ args }) { runCreate(args); },
|
||||
}),
|
||||
install,
|
||||
uninstall: defineCommand({
|
||||
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
|
||||
run() { runUninstall(); },
|
||||
}),
|
||||
join,
|
||||
list: defineCommand({
|
||||
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
|
||||
run() { runList(); },
|
||||
}),
|
||||
leave,
|
||||
peers: defineCommand({
|
||||
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runPeers(args); },
|
||||
}),
|
||||
send: defineCommand({
|
||||
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
|
||||
args: {
|
||||
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
|
||||
message: { type: "positional", description: "Message text", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
|
||||
},
|
||||
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||
}),
|
||||
inbox: defineCommand({
|
||||
meta: { name: "inbox", description: "Drain pending inbound messages" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||
},
|
||||
}),
|
||||
state: defineCommand({
|
||||
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
|
||||
args: {
|
||||
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
|
||||
key: { type: "positional", description: "State key (required for `get` and `set`)" },
|
||||
value: { type: "positional", description: "Value to store (required for `set`)" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.action === "list") {
|
||||
await runStateList(args);
|
||||
} else if (args.action === "get") {
|
||||
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||
await runStateGet(args, args.key);
|
||||
} else if (args.action === "set") {
|
||||
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||
await runStateSet(args, args.key, args.value);
|
||||
} else {
|
||||
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
info: defineCommand({
|
||||
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runInfo(args); },
|
||||
}),
|
||||
remember: defineCommand({
|
||||
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
|
||||
args: {
|
||||
content: { type: "positional", description: "Text to store", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRemember(args, args.content); },
|
||||
}),
|
||||
recall: defineCommand({
|
||||
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
|
||||
args: {
|
||||
query: { type: "positional", description: "Full-text search query", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRecall(args, args.query); },
|
||||
}),
|
||||
remind: defineCommand({
|
||||
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
|
||||
args: {
|
||||
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
|
||||
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
|
||||
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
|
||||
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
|
||||
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
|
||||
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
// Collect positional args from rawArgs (before any flags)
|
||||
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
}),
|
||||
doctor: defineCommand({
|
||||
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
|
||||
async run() { await runDoctor(); },
|
||||
}),
|
||||
mcp: defineCommand({
|
||||
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
|
||||
async run() { await startMcpServer(); },
|
||||
}),
|
||||
"seed-test-mesh": defineCommand({
|
||||
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
|
||||
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||
}),
|
||||
hook: defineCommand({
|
||||
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
|
||||
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||
}),
|
||||
},
|
||||
run() {
|
||||
runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
|
||||
@@ -24,6 +24,22 @@ import type {
|
||||
} from "./types";
|
||||
import type { BrokerClient, InboundPush } from "../ws/client";
|
||||
|
||||
/** Compute a human-readable relative time string from an ISO timestamp. */
|
||||
function relativeTime(isoStr: string): string {
|
||||
const then = new Date(isoStr).getTime();
|
||||
if (isNaN(then)) return "unknown";
|
||||
const diffMs = Date.now() - then;
|
||||
if (diffMs < 0) return "just now";
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
function text(msg: string, isError = false) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: msg }],
|
||||
@@ -123,13 +139,15 @@ function decryptFailedWarning(senderPubkey: string): string {
|
||||
|
||||
function formatPush(p: InboundPush, meshSlug: string): string {
|
||||
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
|
||||
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
||||
const tag = p.subtype === "reminder" ? " [REMINDER]" : "";
|
||||
return `[${meshSlug}]${tag} from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
||||
}
|
||||
|
||||
export async function startMcpServer(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
const myName = config.displayName ?? "unnamed";
|
||||
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
|
||||
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
|
||||
const messageMode = config.messageMode ?? "push";
|
||||
|
||||
@@ -141,11 +159,13 @@ export async function startMcpServer(): Promise<void> {
|
||||
tools: {},
|
||||
},
|
||||
instructions: `## Identity
|
||||
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
||||
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
||||
|
||||
## Responding to messages
|
||||
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
|
||||
|
||||
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
|
||||
|
||||
## Tools
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@@ -154,6 +174,8 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
|
||||
| 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. |
|
||||
| set_visible(visible) | Toggle visibility. Hidden peers skip list_peers and broadcasts; direct messages still arrive. |
|
||||
| set_profile(avatar?, title?, bio?, capabilities?) | Set public profile: emoji avatar, short title, bio, capabilities list. |
|
||||
| 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. |
|
||||
@@ -187,6 +209,15 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
|
||||
| claim_task(id) | Claim an unclaimed task. |
|
||||
| complete_task(id, result?) | Mark task done with optional result. |
|
||||
| list_tasks(status?, assignee?) | List tasks filtered by status/assignee. |
|
||||
| schedule_reminder(message, in_seconds?, deliver_at?, to?) | Schedule a reminder to yourself (no \`to\`) or a delayed message to a peer/group. Delivered as a push with \`subtype: reminder\` in the channel meta. |
|
||||
| list_scheduled() | List pending scheduled reminders and messages. |
|
||||
| cancel_scheduled(id) | Cancel a pending scheduled item. |
|
||||
| read_peer_file(peer, path) | Read a file from another peer's project (max 1MB). |
|
||||
| list_peer_files(peer, path?, pattern?) | List files in a peer's shared directory. |
|
||||
| mesh_mcp_register(server_name, description, tools) | Register an MCP server with the mesh. Other peers can call its tools. |
|
||||
| mesh_mcp_list() | List MCP servers available in the mesh with their tools. |
|
||||
| mesh_tool_call(server_name, tool_name, args?) | Call a tool on a mesh-registered MCP server (30s timeout). |
|
||||
| mesh_mcp_remove(server_name) | Unregister an MCP server you registered. |
|
||||
|
||||
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
|
||||
|
||||
@@ -209,9 +240,14 @@ Shared key-value store scoped to the mesh. Use get_state/set_state for live coor
|
||||
## 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.
|
||||
## File access — decision guide
|
||||
Three ways to access files. Pick the right one:
|
||||
|
||||
1. **Local peer (same machine, [local] tag):** Read files directly via filesystem using their \`cwd\` path from list_peers. No limit, instant. This is the default for local peers.
|
||||
2. **Remote peer (different machine, [remote] tag):** Use \`read_peer_file(peer, path)\` — relays through the mesh. **1 MB limit**, base64 encoded. Use \`list_peer_files\` to browse first.
|
||||
3. **Persistent sharing (any peer):** Use \`share_file(path)\` — uploads to mesh storage (MinIO). **No size limit**. All peers can download anytime via \`get_file\`. Use for files that need to persist or be shared with multiple peers.
|
||||
|
||||
**Rule of thumb:** local peer → filesystem. Remote peer, small file → read_peer_file. Large file or needs to persist → share_file.
|
||||
|
||||
## Vectors
|
||||
Store and search semantic embeddings. Use vector_store to index content, vector_search to find similar content.
|
||||
@@ -253,6 +289,12 @@ Your message mode is "${messageMode}".
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const { name, arguments: args } = req.params;
|
||||
|
||||
// Track tool call count across all connected clients
|
||||
for (const c of allClients()) {
|
||||
c.incrementToolCalls();
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
return text(
|
||||
"No meshes joined. Run `claudemesh join https://claudemesh.com/join/<token>` first.",
|
||||
@@ -315,7 +357,18 @@ Your message mode is "${messageMode}".
|
||||
const peerLines = peers.map((p) => {
|
||||
const summary = p.summary ? ` — "${p.summary}"` : "";
|
||||
const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : "";
|
||||
return `- **${p.displayName}** [${p.status}]${groupsStr} (${p.pubkey.slice(0, 12)}…)${summary}`;
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(`type:${p.peerType}`);
|
||||
if (p.channel) meta.push(`channel:${p.channel}`);
|
||||
if (p.model) meta.push(`model:${p.model}`);
|
||||
const metaStr = meta.length ? ` {${meta.join(", ")}}` : "";
|
||||
const cwdStr = p.cwd ? ` cwd:${p.cwd}` : "";
|
||||
const locality = p.hostname && p.hostname === require("os").hostname() ? "local" : "remote";
|
||||
const localityTag = ` [${locality}]`;
|
||||
const profileAvatar = p.profile?.avatar ? `${p.profile.avatar} ` : "";
|
||||
const profileTitle = p.profile?.title ? ` (${p.profile.title})` : "";
|
||||
const hiddenTag = p.visible === false ? " [hidden]" : "";
|
||||
return `- ${profileAvatar}**${p.displayName}**${profileTitle} [${p.status}]${localityTag}${hiddenTag}${groupsStr}${metaStr} (${p.pubkey.slice(0, 12)}…)${cwdStr}${summary}`;
|
||||
});
|
||||
sections.push(`${header}\n${peerLines.join("\n")}`);
|
||||
}
|
||||
@@ -326,9 +379,15 @@ Your message mode is "${messageMode}".
|
||||
case "message_status": {
|
||||
const { id } = (args ?? {}) as { id?: string };
|
||||
if (!id) return text("message_status: `id` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("message_status: not connected", true);
|
||||
const result = await client.messageStatus(id);
|
||||
const clients = allClients();
|
||||
if (!clients.length) return text("message_status: not connected", true);
|
||||
// Try each connected mesh client — we don't know which mesh the
|
||||
// messageId belongs to, so query all and return the first hit.
|
||||
let result = null;
|
||||
for (const c of clients) {
|
||||
result = await c.messageStatus(id);
|
||||
if (result) break;
|
||||
}
|
||||
if (!result) return text(`Message ${id} not found or timed out.`);
|
||||
const recipientLines = result.recipients.map(
|
||||
(r: { name: string; pubkey: string; status: string }) =>
|
||||
@@ -370,6 +429,25 @@ Your message mode is "${messageMode}".
|
||||
return text(`Status set to ${s} across ${allClients().length} mesh(es).`);
|
||||
}
|
||||
|
||||
case "set_visible": {
|
||||
const { visible } = (args ?? {}) as { visible?: boolean };
|
||||
if (visible === undefined) return text("set_visible: `visible` required", true);
|
||||
for (const c of allClients()) await c.setVisible(visible);
|
||||
return text(visible ? "You are now visible to peers." : "You are now hidden. Direct messages still reach you, but you won't appear in list_peers or receive broadcasts.");
|
||||
}
|
||||
|
||||
case "set_profile": {
|
||||
const { avatar, title, bio, capabilities } = (args ?? {}) as { avatar?: string; title?: string; bio?: string; capabilities?: string[] };
|
||||
const profile = { avatar, title, bio, capabilities };
|
||||
for (const c of allClients()) await c.setProfile(profile);
|
||||
const parts: string[] = [];
|
||||
if (avatar) parts.push(`Avatar: ${avatar}`);
|
||||
if (title) parts.push(`Title: ${title}`);
|
||||
if (bio) parts.push(`Bio: ${bio}`);
|
||||
if (capabilities?.length) parts.push(`Capabilities: ${capabilities.join(", ")}`);
|
||||
return text(parts.length > 0 ? `Profile updated:\n${parts.join("\n")}` : "Profile cleared.");
|
||||
}
|
||||
|
||||
case "join_group": {
|
||||
const { name: groupName, role } = (args ?? {}) as { name?: string; role?: string };
|
||||
if (!groupName) return text("join_group: `name` required", true);
|
||||
@@ -437,14 +515,157 @@ Your message mode is "${messageMode}".
|
||||
return text(`Forgotten: ${id}`);
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
case "schedule_reminder": {
|
||||
const sArgs = (args ?? {}) as {
|
||||
message?: string;
|
||||
to?: string;
|
||||
deliver_at?: number;
|
||||
in_seconds?: number;
|
||||
cron?: string;
|
||||
};
|
||||
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
|
||||
|
||||
const isCron = !!sArgs.cron;
|
||||
|
||||
let deliverAt: number;
|
||||
if (isCron) {
|
||||
// For cron, deliverAt is ignored by the broker — set to 0
|
||||
deliverAt = 0;
|
||||
} else if (sArgs.deliver_at) {
|
||||
deliverAt = Number(sArgs.deliver_at);
|
||||
} else if (sArgs.in_seconds) {
|
||||
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
|
||||
} else {
|
||||
return text("schedule_reminder: provide `deliver_at` (ms timestamp), `in_seconds`, or `cron` expression", true);
|
||||
}
|
||||
|
||||
const isSelf = !sArgs.to;
|
||||
let targetSpec: string;
|
||||
if (isSelf) {
|
||||
// Self-reminder: target own session pubkey
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
} else {
|
||||
const to = sArgs.to!;
|
||||
// Resolve display name → pubkey if not a raw spec
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
return text(`schedule_reminder: peer "${to}" not found. Online: ${names || "(none)"}`, true);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
} else {
|
||||
targetSpec = to;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true, sArgs.cron);
|
||||
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
|
||||
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toISOString();
|
||||
return text(
|
||||
isSelf
|
||||
? `Recurring self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" — cron: ${sArgs.cron}, next fire: ${nextFire}`
|
||||
: `Recurring reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) — cron: ${sArgs.cron}, next fire: ${nextFire}`,
|
||||
);
|
||||
}
|
||||
|
||||
const when = new Date(result.deliverAt).toISOString();
|
||||
return text(
|
||||
isSelf
|
||||
? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
|
||||
: `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
|
||||
);
|
||||
}
|
||||
case "list_scheduled": {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (scheduled.length === 0) return text("No pending scheduled messages.");
|
||||
const lines = scheduled.map((m) =>
|
||||
`- [${m.id.slice(0, 8)}] → ${m.to === client.getSessionPubkey() ? "self (reminder)" : m.to} at ${new Date(m.deliverAt).toISOString()}: "${m.message.slice(0, 60)}${m.message.length > 60 ? "…" : ""}"`,
|
||||
);
|
||||
return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`);
|
||||
}
|
||||
case "cancel_scheduled": {
|
||||
const { id: schedId } = (args ?? {}) as { id?: string };
|
||||
if (!schedId) return text("cancel_scheduled: `id` required", true);
|
||||
const ok = await client.cancelScheduled(schedId);
|
||||
return text(ok ? `Cancelled: ${schedId}` : `Not found or already fired: ${schedId}`, !ok);
|
||||
}
|
||||
|
||||
// --- Files ---
|
||||
case "share_file": {
|
||||
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] };
|
||||
const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string };
|
||||
if (!filePath) return text("share_file: `path` required", true);
|
||||
const { existsSync } = await import("node:fs");
|
||||
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("share_file: not connected", true);
|
||||
|
||||
// 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,
|
||||
@@ -462,6 +683,43 @@ Your message mode is "${messageMode}".
|
||||
if (!client) return text("get_file: not connected", true);
|
||||
const result = await client.getFile(id);
|
||||
if (!result) return text(`get_file: file ${id} not found`, true);
|
||||
|
||||
if (result.encrypted) {
|
||||
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
|
||||
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
|
||||
const { ensureSodium } = await import("../crypto/keypair");
|
||||
const myPubkey = client.getSessionPubkey();
|
||||
const mySecret = client.getSessionSecretKey();
|
||||
|
||||
if (!myPubkey || !mySecret) {
|
||||
return text("get_file: no session keypair — cannot decrypt", true);
|
||||
}
|
||||
|
||||
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
|
||||
if (!kf) return text("get_file: failed to open sealed key", true);
|
||||
|
||||
// Download file bytes from presigned URL
|
||||
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
|
||||
const buf = new Uint8Array(await resp.arrayBuffer());
|
||||
|
||||
// Wire format: first 24 bytes = nonce, rest = ciphertext
|
||||
const sodium = await ensureSodium();
|
||||
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
|
||||
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
|
||||
const ciphertext = buf.slice(NONCE_BYTES);
|
||||
|
||||
const plaintext = await decryptFile(ciphertext, nonce, kf);
|
||||
if (!plaintext) return text("get_file: decryption failed", true);
|
||||
|
||||
const { writeFileSync, mkdirSync } = await import("node:fs");
|
||||
const { dirname } = await import("node:path");
|
||||
mkdirSync(dirname(save_to), { recursive: true });
|
||||
writeFileSync(save_to, plaintext);
|
||||
return text(`Downloaded and decrypted: ${result.name} → ${save_to}`);
|
||||
}
|
||||
|
||||
// Unencrypted — existing download logic
|
||||
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
|
||||
const { writeFileSync, mkdirSync } = await import("node:fs");
|
||||
@@ -689,6 +947,63 @@ Your message mode is "${messageMode}".
|
||||
return text(lines.join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_set_clock": {
|
||||
const { speed } = (args ?? {}) as { speed?: number };
|
||||
if (!speed || speed < 1 || speed > 100) return text("mesh_set_clock: speed must be 1-100", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_set_clock: not connected", true);
|
||||
const result = await client.setClock(speed);
|
||||
if (!result) return text("mesh_set_clock: timed out", true);
|
||||
return text([
|
||||
`**Clock set to x${result.speed}**`,
|
||||
`Paused: ${result.paused}`,
|
||||
`Tick: ${result.tick}`,
|
||||
`Sim time: ${result.simTime}`,
|
||||
`Started at: ${result.startedAt}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_pause_clock": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_pause_clock: not connected", true);
|
||||
const result = await client.pauseClock();
|
||||
if (!result) return text("mesh_pause_clock: timed out", true);
|
||||
return text([
|
||||
"**Clock paused**",
|
||||
`Speed: x${result.speed}`,
|
||||
`Tick: ${result.tick}`,
|
||||
`Sim time: ${result.simTime}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_resume_clock": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_resume_clock: not connected", true);
|
||||
const result = await client.resumeClock();
|
||||
if (!result) return text("mesh_resume_clock: timed out", true);
|
||||
return text([
|
||||
"**Clock resumed**",
|
||||
`Speed: x${result.speed}`,
|
||||
`Tick: ${result.tick}`,
|
||||
`Sim time: ${result.simTime}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_clock": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_clock: not connected", true);
|
||||
const result = await client.getClock();
|
||||
if (!result) return text("mesh_clock: timed out", true);
|
||||
const statusLabel = result.speed === 0 ? "not started" : result.paused ? "paused" : "running";
|
||||
return text([
|
||||
`**Clock status: ${statusLabel}**`,
|
||||
`Speed: x${result.speed}`,
|
||||
`Tick: ${result.tick}`,
|
||||
`Sim time: ${result.simTime}`,
|
||||
`Started at: ${result.startedAt}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_info": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_info: not connected", true);
|
||||
@@ -710,6 +1025,73 @@ Your message mode is "${messageMode}".
|
||||
return text(lines.join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_stats": {
|
||||
const clients = allClients();
|
||||
if (clients.length === 0) return text("mesh_stats: no joined meshes", true);
|
||||
const sections: string[] = [];
|
||||
for (const c of clients) {
|
||||
const peers = await c.listPeers();
|
||||
const header = `## ${c.meshSlug}`;
|
||||
const rows = peers.map((p) => {
|
||||
const s = p.stats;
|
||||
if (!s) return `| ${p.displayName} | - | - | - | - | - |`;
|
||||
const up = s.uptime != null ? `${Math.floor(s.uptime / 60)}m` : "-";
|
||||
return `| ${p.displayName} | ${s.messagesIn ?? 0} | ${s.messagesOut ?? 0} | ${s.toolCalls ?? 0} | ${up} | ${s.errors ?? 0} |`;
|
||||
});
|
||||
sections.push(
|
||||
`${header}\n| Peer | Msgs In | Msgs Out | Tool Calls | Uptime | Errors |\n|------|---------|----------|------------|--------|--------|\n${rows.join("\n")}`,
|
||||
);
|
||||
}
|
||||
return text(sections.join("\n\n"));
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
case "share_skill": {
|
||||
const { name: skillName, description: skillDesc, instructions: skillInstr, tags: skillTags } = (args ?? {}) as { name?: string; description?: string; instructions?: string; tags?: string[] };
|
||||
if (!skillName || !skillDesc || !skillInstr) return text("share_skill: `name`, `description`, and `instructions` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("share_skill: not connected", true);
|
||||
const result = await client.shareSkill(skillName, skillDesc, skillInstr, skillTags);
|
||||
if (!result) return text("share_skill: broker did not acknowledge", true);
|
||||
return text(`Skill "${skillName}" published to the mesh.`);
|
||||
}
|
||||
case "get_skill": {
|
||||
const { name: gsName } = (args ?? {}) as { name?: string };
|
||||
if (!gsName) return text("get_skill: `name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("get_skill: not connected", true);
|
||||
const skill = await client.getSkill(gsName);
|
||||
if (!skill) return text(`Skill "${gsName}" not found in the mesh.`);
|
||||
return text(
|
||||
`# Skill: ${skill.name}\n\n` +
|
||||
`**Description:** ${skill.description}\n` +
|
||||
`**Author:** ${skill.author}\n` +
|
||||
`**Tags:** ${skill.tags.length ? skill.tags.join(", ") : "none"}\n` +
|
||||
`**Created:** ${skill.createdAt}\n\n` +
|
||||
`---\n\n` +
|
||||
`## Instructions\n\n${skill.instructions}`,
|
||||
);
|
||||
}
|
||||
case "list_skills": {
|
||||
const { query: skillQuery } = (args ?? {}) as { query?: string };
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_skills: not connected", true);
|
||||
const skills = await client.listSkills(skillQuery);
|
||||
if (skills.length === 0) return text(skillQuery ? `No skills found for "${skillQuery}".` : "No skills in the mesh yet.");
|
||||
const lines = skills.map(s =>
|
||||
`- **${s.name}**: ${s.description}${s.tags.length ? ` [${s.tags.join(", ")}]` : ""} (by ${s.author})`,
|
||||
);
|
||||
return text(`${skills.length} skill(s):\n${lines.join("\n")}`);
|
||||
}
|
||||
case "remove_skill": {
|
||||
const { name: rsName } = (args ?? {}) as { name?: string };
|
||||
if (!rsName) return text("remove_skill: `name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("remove_skill: not connected", true);
|
||||
const removed = await client.removeSkill(rsName);
|
||||
return text(removed ? `Skill "${rsName}" removed.` : `Skill "${rsName}" not found.`, !removed);
|
||||
}
|
||||
|
||||
case "ping_mesh": {
|
||||
const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] };
|
||||
const toTest = (pingPriorities ?? ["now", "next"]) as Priority[];
|
||||
@@ -760,6 +1142,197 @@ Your message mode is "${messageMode}".
|
||||
return text(results.join("\n"));
|
||||
}
|
||||
|
||||
// --- MCP Proxy ---
|
||||
case "mesh_mcp_register": {
|
||||
const { server_name, description, tools: regTools } = (args ?? {}) as {
|
||||
server_name?: string;
|
||||
description?: string;
|
||||
tools?: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
|
||||
};
|
||||
if (!server_name || !description || !regTools?.length)
|
||||
return text("mesh_mcp_register: `server_name`, `description`, and `tools` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_mcp_register: not connected", true);
|
||||
const result = await client.mcpRegister(server_name, description, regTools);
|
||||
if (!result) return text("mesh_mcp_register: broker did not acknowledge", true);
|
||||
return text(`Registered MCP server "${result.serverName}" with ${result.toolCount} tool(s). Other peers can now call its tools via mesh_tool_call.`);
|
||||
}
|
||||
case "mesh_mcp_list": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_mcp_list: not connected", true);
|
||||
const servers = await client.mcpList();
|
||||
if (servers.length === 0) return text("No MCP servers registered in the mesh.");
|
||||
const lines = servers.map((s) => {
|
||||
const toolList = s.tools.map((t) => ` - **${t.name}**: ${t.description}`).join("\n");
|
||||
return `- **${s.name}** (hosted by ${s.hostedBy}): ${s.description}\n${toolList}`;
|
||||
});
|
||||
return text(`${servers.length} MCP server(s) in mesh:\n${lines.join("\n")}`);
|
||||
}
|
||||
case "mesh_tool_call": {
|
||||
const { server_name: callServer, tool_name: callTool, args: callArgs } = (args ?? {}) as {
|
||||
server_name?: string;
|
||||
tool_name?: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
if (!callServer || !callTool)
|
||||
return text("mesh_tool_call: `server_name` and `tool_name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_tool_call: not connected", true);
|
||||
const callResult = await client.mcpCall(callServer, callTool, callArgs ?? {});
|
||||
if (callResult.error) return text(`mesh_tool_call error: ${callResult.error}`, true);
|
||||
return text(typeof callResult.result === "string" ? callResult.result : JSON.stringify(callResult.result, null, 2));
|
||||
}
|
||||
case "mesh_mcp_remove": {
|
||||
const { server_name: rmServer } = (args ?? {}) as { server_name?: string };
|
||||
if (!rmServer) return text("mesh_mcp_remove: `server_name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_mcp_remove: not connected", true);
|
||||
await client.mcpUnregister(rmServer);
|
||||
return text(`Unregistered MCP server "${rmServer}" from the mesh.`);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// --- Peer file sharing ---
|
||||
case "read_peer_file": {
|
||||
const { peer: peerName, path: filePath } = (args ?? {}) as { peer?: string; path?: string };
|
||||
if (!peerName || !filePath) return text("read_peer_file: `peer` and `path` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("read_peer_file: not connected", true);
|
||||
|
||||
// Resolve peer name to pubkey
|
||||
const peers = await client.listPeers();
|
||||
const nameLower = peerName.toLowerCase();
|
||||
let targetPubkey: string | null = null;
|
||||
// Direct pubkey?
|
||||
if (/^[0-9a-f]{64}$/.test(peerName)) {
|
||||
targetPubkey = peerName;
|
||||
} else {
|
||||
const match = peers.find(p => p.displayName.toLowerCase() === nameLower);
|
||||
if (!match) {
|
||||
const partials = peers.filter(p => p.displayName.toLowerCase().includes(nameLower));
|
||||
if (partials.length === 1) {
|
||||
targetPubkey = partials[0]!.pubkey;
|
||||
} else {
|
||||
const names = peers.map(p => p.displayName).join(", ");
|
||||
return text(`read_peer_file: peer "${peerName}" not found. Online: ${names || "(none)"}`, true);
|
||||
}
|
||||
} else {
|
||||
targetPubkey = match.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if peer is local — hint AI to use filesystem directly
|
||||
const resolvedPeer = peers.find(p => p.pubkey === targetPubkey);
|
||||
const isLocal = resolvedPeer?.hostname && resolvedPeer.hostname === require("os").hostname();
|
||||
let localHint = "";
|
||||
if (isLocal && resolvedPeer?.cwd) {
|
||||
const directPath = require("path").resolve(resolvedPeer.cwd, filePath);
|
||||
localHint = `\n\n> **Hint:** This peer is LOCAL (same machine). Next time, read directly: \`${directPath}\` — faster, no size limit.\n\n`;
|
||||
}
|
||||
|
||||
const result = await client.requestFile(targetPubkey, filePath);
|
||||
if (result.error) return text(`read_peer_file: ${result.error}`, true);
|
||||
if (!result.content) return text("read_peer_file: empty response from peer", true);
|
||||
|
||||
// Decode base64
|
||||
try {
|
||||
const decoded = Buffer.from(result.content, "base64").toString("utf-8");
|
||||
return text(localHint + decoded);
|
||||
} catch {
|
||||
return text("read_peer_file: failed to decode file content (binary file?)", true);
|
||||
}
|
||||
}
|
||||
|
||||
case "list_peer_files": {
|
||||
const { peer: peerName, path: dirPath, pattern } = (args ?? {}) as { peer?: string; path?: string; pattern?: string };
|
||||
if (!peerName) return text("list_peer_files: `peer` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_peer_files: not connected", true);
|
||||
|
||||
// Resolve peer name to pubkey
|
||||
const peers = await client.listPeers();
|
||||
const nameLower = peerName.toLowerCase();
|
||||
let targetPubkey: string | null = null;
|
||||
if (/^[0-9a-f]{64}$/.test(peerName)) {
|
||||
targetPubkey = peerName;
|
||||
} else {
|
||||
const match = peers.find(p => p.displayName.toLowerCase() === nameLower);
|
||||
if (!match) {
|
||||
const partials = peers.filter(p => p.displayName.toLowerCase().includes(nameLower));
|
||||
if (partials.length === 1) {
|
||||
targetPubkey = partials[0]!.pubkey;
|
||||
} else {
|
||||
const names = peers.map(p => p.displayName).join(", ");
|
||||
return text(`list_peer_files: peer "${peerName}" not found. Online: ${names || "(none)"}`, true);
|
||||
}
|
||||
} else {
|
||||
targetPubkey = match.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.requestDir(targetPubkey, dirPath ?? ".", pattern);
|
||||
if (result.error) return text(`list_peer_files: ${result.error}`, true);
|
||||
if (!result.entries || result.entries.length === 0) return text("No files found.");
|
||||
|
||||
return text(result.entries.join("\n"));
|
||||
}
|
||||
|
||||
// --- Webhooks ---
|
||||
case "create_webhook": {
|
||||
const { name: whName } = (args ?? {}) as { name?: string };
|
||||
if (!whName) return text("create_webhook: `name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("create_webhook: not connected", true);
|
||||
const wh = await client.createWebhook(whName);
|
||||
if (!wh) return text("create_webhook: broker did not acknowledge — check connection", true);
|
||||
return text(`Webhook **${wh.name}** created.\n\nURL: ${wh.url}\nSecret: ${wh.secret}\n\nExternal services can POST JSON to this URL. The payload will be pushed to all connected mesh peers.`);
|
||||
}
|
||||
case "list_webhooks": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_webhooks: not connected", true);
|
||||
const webhooks = await client.listWebhooks();
|
||||
if (webhooks.length === 0) return text("No active webhooks.");
|
||||
const lines = webhooks.map(w => `- **${w.name}** — ${w.url} (created ${w.createdAt})`);
|
||||
return text(`${webhooks.length} webhook(s):\n${lines.join("\n")}`);
|
||||
}
|
||||
case "delete_webhook": {
|
||||
const { name: delName } = (args ?? {}) as { name?: string };
|
||||
if (!delName) return text("delete_webhook: `name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("delete_webhook: not connected", true);
|
||||
const ok = await client.deleteWebhook(delName);
|
||||
return text(ok ? `Webhook "${delName}" deactivated.` : `Failed to deactivate webhook "${delName}".`, !ok);
|
||||
}
|
||||
|
||||
default:
|
||||
return text(`Unknown tool: ${name}`, true);
|
||||
}
|
||||
@@ -783,6 +1356,57 @@ Your message mode is "${messageMode}".
|
||||
client.onPush(async (msg) => {
|
||||
if (messageMode === "off") return;
|
||||
|
||||
// System events (peer join/leave) — always push, regardless of mode.
|
||||
if (msg.subtype === "system" && msg.event) {
|
||||
const eventName = msg.event;
|
||||
const data = msg.eventData ?? {};
|
||||
let content: string;
|
||||
if (eventName === "tick") {
|
||||
const tick = data.tick ?? 0;
|
||||
const simTime = String(data.simTime ?? "").replace("T", " ").replace(/\..*/,"");
|
||||
const speed = data.speed ?? 1;
|
||||
content = `[heartbeat] tick ${tick} | sim time: ${simTime} | speed: x${speed}`;
|
||||
} else if (eventName === "peer_joined") {
|
||||
content = `[system] Peer "${data.name ?? "unknown"}" joined the mesh`;
|
||||
} else if (eventName === "peer_returned") {
|
||||
const peerName = String(data.name ?? "unknown");
|
||||
const lastSeenAt = data.lastSeenAt ? relativeTime(String(data.lastSeenAt)) : "unknown";
|
||||
const groups = Array.isArray(data.groups)
|
||||
? (data.groups as Array<{ name: string; role?: string }>).map((g) => g.role ? `@${g.name}:${g.role}` : `@${g.name}`).join(", ")
|
||||
: "";
|
||||
const summary = data.summary ? ` Summary: "${data.summary}"` : "";
|
||||
content = `[system] Welcome back, "${peerName}"! Last seen ${lastSeenAt}.${groups ? ` Restored: ${groups}` : ""}${summary}`;
|
||||
} else if (eventName === "peer_left") {
|
||||
content = `[system] Peer "${data.name ?? "unknown"}" left the mesh`;
|
||||
} else if (eventName === "mcp_registered") {
|
||||
const tools = Array.isArray(data.tools) ? (data.tools as string[]).join(", ") : "";
|
||||
content = `[system] New MCP server available: "${data.serverName}" (hosted by ${data.hostedBy}). Tools: ${tools}. Use mesh_tool_call to invoke.`;
|
||||
} else if (eventName === "mcp_unregistered") {
|
||||
content = `[system] MCP server "${data.serverName}" removed (was hosted by ${data.hostedBy})`;
|
||||
} else {
|
||||
content = `[system] ${eventName}: ${JSON.stringify(data)}`;
|
||||
}
|
||||
try {
|
||||
await server.notification({
|
||||
method: "notifications/claude/channel",
|
||||
params: {
|
||||
content,
|
||||
meta: {
|
||||
kind: "system",
|
||||
event: eventName,
|
||||
mesh_slug: client.meshSlug,
|
||||
mesh_id: client.meshId,
|
||||
...(Object.keys(data).length > 0 ? { eventData: data } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
process.stderr.write(`[claudemesh] system: ${content}\n`);
|
||||
} catch (pushErr) {
|
||||
process.stderr.write(`[claudemesh] system push FAILED: ${pushErr}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fromPubkey = msg.senderPubkey || "";
|
||||
const fromName = fromPubkey
|
||||
? await resolvePeerName(client, fromPubkey)
|
||||
@@ -817,6 +1441,7 @@ Your message mode is "${messageMode}".
|
||||
sent_at: msg.createdAt,
|
||||
delivered_at: msg.receivedAt,
|
||||
kind: msg.kind,
|
||||
...(msg.subtype ? { subtype: msg.subtype } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,6 +96,48 @@ export const TOOLS: Tool[] = [
|
||||
required: ["status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_visible",
|
||||
description:
|
||||
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: {
|
||||
type: "boolean",
|
||||
description: "true to be visible (default), false to hide",
|
||||
},
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_profile",
|
||||
description:
|
||||
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
avatar: {
|
||||
type: "string",
|
||||
description: "Emoji or URL for your avatar",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
description: "One-liner about yourself",
|
||||
},
|
||||
capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What you can help with",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join_group",
|
||||
description:
|
||||
@@ -203,7 +245,7 @@ export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "share_file",
|
||||
description:
|
||||
"Share a persistent file with the mesh. All current and future peers can access it.",
|
||||
"Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -217,6 +259,10 @@ export const TOOLS: Tool[] = [
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
@@ -269,6 +315,18 @@ export const TOOLS: Tool[] = [
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grant_file_access",
|
||||
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: { type: "string", description: "File ID" },
|
||||
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
|
||||
},
|
||||
required: ["fileId", "to"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Vector tools ---
|
||||
{
|
||||
@@ -548,6 +606,43 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Scheduled messages ---
|
||||
{
|
||||
name: "schedule_reminder",
|
||||
description:
|
||||
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Message or reminder text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
|
||||
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_scheduled",
|
||||
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "cancel_scheduled",
|
||||
description: "Cancel a pending scheduled message before it fires.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Scheduled message ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh info ---
|
||||
{
|
||||
name: "mesh_info",
|
||||
@@ -556,6 +651,166 @@ export const TOOLS: Tool[] = [
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Stats ---
|
||||
{
|
||||
name: "mesh_stats",
|
||||
description:
|
||||
"View resource usage stats for all peers: messages sent/received, tool calls, uptime, errors.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- MCP Proxy ---
|
||||
{
|
||||
name: "mesh_mcp_register",
|
||||
description:
|
||||
"Register an MCP server with the mesh. Other peers can invoke its tools through the mesh without restarting their sessions. Provide the server name, description, and full tool definitions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Unique name for the MCP server (e.g. 'github', 'jira')" },
|
||||
description: { type: "string", description: "What this MCP server does" },
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
inputSchema: { type: "object", description: "JSON Schema for tool arguments" },
|
||||
},
|
||||
required: ["name", "description", "inputSchema"],
|
||||
},
|
||||
description: "Tool definitions to expose",
|
||||
},
|
||||
},
|
||||
required: ["server_name", "description", "tools"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_list",
|
||||
description:
|
||||
"List MCP servers available in the mesh with their tools. Shows which peer hosts each server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_tool_call",
|
||||
description:
|
||||
"Call a tool on a mesh-registered MCP server. Route: you -> broker -> hosting peer -> execute -> result back. Timeout: 30s.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Name of the MCP server" },
|
||||
tool_name: { type: "string", description: "Name of the tool to call" },
|
||||
args: { type: "object", description: "Tool arguments (JSON object)" },
|
||||
},
|
||||
required: ["server_name", "tool_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_remove",
|
||||
description:
|
||||
"Unregister an MCP server you previously registered with the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Name of the MCP server to remove" },
|
||||
},
|
||||
required: ["server_name"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// --- Simulation clock tools ---
|
||||
{
|
||||
name: "mesh_set_clock",
|
||||
description:
|
||||
"Set the simulation clock speed. x1 = real-time, x10 = 10x faster, x100 = 100x. Peers receive heartbeat ticks at the simulated rate.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
speed: {
|
||||
type: "number",
|
||||
description: "Speed multiplier (1-100). x1 = tick every 60s, x10 = tick every 6s, x100 = tick every 600ms.",
|
||||
},
|
||||
},
|
||||
required: ["speed"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_pause_clock",
|
||||
description:
|
||||
"Pause the simulation clock. Ticks stop until resumed.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_resume_clock",
|
||||
description:
|
||||
"Resume a paused simulation clock.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_clock",
|
||||
description:
|
||||
"Get current simulation clock status: speed, tick count, simulated time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Skills ---
|
||||
{
|
||||
name: "share_skill",
|
||||
description:
|
||||
"Publish a reusable skill to the mesh. Other peers can discover and load it. If a skill with the same name exists, it is updated.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist')" },
|
||||
description: { type: "string", description: "Short description of what the skill does" },
|
||||
instructions: { type: "string", description: "Full instructions/prompt that a peer loads to acquire this capability" },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for discoverability",
|
||||
},
|
||||
},
|
||||
required: ["name", "description", "instructions"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_skill",
|
||||
description:
|
||||
"Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to load" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_skills",
|
||||
description:
|
||||
"Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search keyword (optional)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_skill",
|
||||
description:
|
||||
"Remove a skill you published from the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to remove" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Diagnostics ---
|
||||
{
|
||||
name: "ping_mesh",
|
||||
@@ -572,4 +827,66 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Peer file sharing ---
|
||||
{
|
||||
name: "read_peer_file",
|
||||
description:
|
||||
"Read a file from another peer's project. Specify the peer (by name) and the file path relative to their working directory. The peer must be online and sharing files. Max file size: 1MB.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||
path: { type: "string", description: "File path relative to peer's working directory" },
|
||||
},
|
||||
required: ["peer", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_peer_files",
|
||||
description:
|
||||
"List files in a peer's shared directory. Returns a tree of file names (not contents). The peer must be online and sharing files.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||
path: { type: "string", description: "Directory path relative to peer's cwd (default: root)" },
|
||||
pattern: { type: "string", description: "Glob-like filter pattern (e.g. '*.ts', 'src/*')" },
|
||||
},
|
||||
required: ["peer"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Webhooks ---
|
||||
{
|
||||
name: "create_webhook",
|
||||
description:
|
||||
"Create an inbound webhook. Returns a URL that external services (GitHub, CI/CD, monitoring) can POST to — the payload becomes a mesh message to all peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Webhook name (e.g. 'github-ci', 'datadog-alerts')",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_webhooks",
|
||||
description: "List active webhooks for this mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "delete_webhook",
|
||||
description: "Deactivate a webhook.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Webhook name to deactivate" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Config {
|
||||
version: 1;
|
||||
meshes: JoinedMesh[];
|
||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||
role?: string; // per-session role tag (display + hello)
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
@@ -54,7 +55,7 @@ export function loadConfig(): Config {
|
||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode };
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
||||
17
apps/cli/src/templates/dev-team.json
Normal file
17
apps/cli/src/templates/dev-team.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dev-team",
|
||||
"description": "Software development team with frontend, backend, and devops groups",
|
||||
"groups": [
|
||||
{ "name": "frontend", "roles": ["lead", "member"] },
|
||||
{ "name": "backend", "roles": ["lead", "member"] },
|
||||
{ "name": "devops", "roles": ["lead", "member"] },
|
||||
{ "name": "qa", "roles": ["lead", "member"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"sprint": "current",
|
||||
"deploy-frozen": "false",
|
||||
"pr-queue": "[]"
|
||||
},
|
||||
"suggestedRoles": ["lead", "member", "reviewer"],
|
||||
"systemPromptHint": "You are part of a dev team. Coordinate with @frontend, @backend, @devops groups. Check state keys for sprint status and deploy freezes before making changes."
|
||||
}
|
||||
30
apps/cli/src/templates/index.ts
Normal file
30
apps/cli/src/templates/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import devTeam from "./dev-team.json" with { type: "json" };
|
||||
import research from "./research.json" with { type: "json" };
|
||||
import opsIncident from "./ops-incident.json" with { type: "json" };
|
||||
import simulation from "./simulation.json" with { type: "json" };
|
||||
import personal from "./personal.json" with { type: "json" };
|
||||
|
||||
export interface MeshTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
groups: Array<{ name: string; roles: string[] }>;
|
||||
stateKeys: Record<string, string>;
|
||||
suggestedRoles: string[];
|
||||
systemPromptHint: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: Record<string, MeshTemplate> = {
|
||||
"dev-team": devTeam,
|
||||
research,
|
||||
"ops-incident": opsIncident,
|
||||
simulation,
|
||||
personal,
|
||||
};
|
||||
|
||||
export function listTemplates(): MeshTemplate[] {
|
||||
return Object.values(TEMPLATES);
|
||||
}
|
||||
|
||||
export function getTemplate(name: string): MeshTemplate | undefined {
|
||||
return TEMPLATES[name];
|
||||
}
|
||||
17
apps/cli/src/templates/ops-incident.json
Normal file
17
apps/cli/src/templates/ops-incident.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ops-incident",
|
||||
"description": "Incident response team with oncall, comms, and engineering groups",
|
||||
"groups": [
|
||||
{ "name": "oncall", "roles": ["primary", "secondary"] },
|
||||
{ "name": "comms", "roles": ["lead", "scribe"] },
|
||||
{ "name": "engineering", "roles": ["lead", "responder"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"incident-status": "investigating",
|
||||
"severity": "unknown",
|
||||
"commander": "",
|
||||
"timeline": "[]"
|
||||
},
|
||||
"suggestedRoles": ["commander", "primary-oncall", "scribe", "responder"],
|
||||
"systemPromptHint": "INCIDENT MODE. Priority: now for all messages. Update incident-status state. Commander coordinates. Scribe maintains timeline. Engineering fixes."
|
||||
}
|
||||
11
apps/cli/src/templates/personal.json
Normal file
11
apps/cli/src/templates/personal.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "personal",
|
||||
"description": "Private mesh for a single user — all sessions auto-join",
|
||||
"groups": [],
|
||||
"stateKeys": {
|
||||
"focus": "",
|
||||
"todos": "[]"
|
||||
},
|
||||
"suggestedRoles": [],
|
||||
"systemPromptHint": "Personal workspace. All your Claude Code sessions share this mesh. Use state keys to track focus and todos across sessions."
|
||||
}
|
||||
16
apps/cli/src/templates/research.json
Normal file
16
apps/cli/src/templates/research.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "research",
|
||||
"description": "Research and analysis team focused on deep investigation and knowledge sharing",
|
||||
"groups": [
|
||||
{ "name": "analysis", "roles": ["lead", "analyst"] },
|
||||
{ "name": "writing", "roles": ["lead", "writer", "reviewer"] },
|
||||
{ "name": "data", "roles": ["engineer", "analyst"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"research-topic": "",
|
||||
"phase": "exploration",
|
||||
"findings-count": "0"
|
||||
},
|
||||
"suggestedRoles": ["lead", "analyst", "writer", "reviewer"],
|
||||
"systemPromptHint": "You are part of a research team. Share findings via remember(), use recall() before starting new analysis. Coordinate phases through state keys."
|
||||
}
|
||||
17
apps/cli/src/templates/simulation.json
Normal file
17
apps/cli/src/templates/simulation.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "simulation",
|
||||
"description": "Load testing simulation with configurable time multiplier and user personas",
|
||||
"groups": [
|
||||
{ "name": "personas", "roles": ["admin", "user", "customer"] },
|
||||
{ "name": "observers", "roles": ["monitor", "analyst"] },
|
||||
{ "name": "control", "roles": ["orchestrator"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"clock-speed": "x1",
|
||||
"sim-status": "paused",
|
||||
"tick-count": "0",
|
||||
"scenario": ""
|
||||
},
|
||||
"suggestedRoles": ["orchestrator", "persona", "monitor"],
|
||||
"systemPromptHint": "SIMULATION MODE. Follow the clock-speed state for time multiplier. Act according to your persona role and the simulated time. Report actions to @observers."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { env } from "../env";
|
||||
|
||||
const clients = new Map<string, BrokerClient>();
|
||||
let configDisplayName: string | undefined;
|
||||
let configGroups: Config["groups"] = [];
|
||||
|
||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
clients.set(mesh.meshId, client);
|
||||
try {
|
||||
await client.connect();
|
||||
// Auto-join groups declared at launch time (--groups flag or config).
|
||||
for (const g of configGroups ?? []) {
|
||||
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||
}
|
||||
} catch {
|
||||
// Connect failed → client is in "reconnecting" state, leave it
|
||||
// wired so tool calls can surface the status.
|
||||
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||
export async function startClients(config: Config): Promise<void> {
|
||||
configDisplayName = config.displayName;
|
||||
configGroups = config.groups ?? [];
|
||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||
}
|
||||
|
||||
|
||||
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 { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { MeshVsMcp } from "~/modules/marketing/home/mesh-vs-mcp";
|
||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
||||
@@ -28,6 +29,7 @@ const HomePage = () => {
|
||||
<Pricing />
|
||||
<LaptopToLaptop />
|
||||
<Features />
|
||||
<MeshVsMcp />
|
||||
<MeetsYou />
|
||||
<WhatIsClaudemesh />
|
||||
<DemoDashboard />
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
DashboardHeaderTitle,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||
import { ResourcePanel } from "~/modules/mesh/resource-panel";
|
||||
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Live mesh",
|
||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<LiveStreamPanel meshId={id} />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<PeerGraphPanel meshId={id} />
|
||||
<LiveStreamPanel meshId={id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<StateTimelinePanel meshId={id} />
|
||||
<ResourcePanel meshId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
||||
},
|
||||
},
|
||||
marketing: {
|
||||
gettingStarted: "/getting-started",
|
||||
pricing: "/pricing",
|
||||
contact: "/contact",
|
||||
blog: {
|
||||
|
||||
@@ -6,7 +6,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||
const INSTALL_CMD = "npx claudemesh@latest init";
|
||||
const INSTALL_CMD = "curl -fsSL https://claudemesh.com/install | bash";
|
||||
|
||||
export const InstallToggle = ({ token }: Props) => {
|
||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||
@@ -106,7 +106,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install + init
|
||||
install the CLI
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -127,8 +127,8 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Generates your ed25519 keypair locally and wires claudemesh into
|
||||
your Claude Code config. You own the keys.
|
||||
Installs the CLI, registers the MCP server + status hooks in
|
||||
Claude Code. Restart Claude Code after this step.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
@@ -161,14 +161,24 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
||||
verify
|
||||
launch with push messaging
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch --name YourName
|
||||
</code>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code session will announce itself to the mesh. Other
|
||||
peers see you appear as a green dot in their dashboard.
|
||||
Restart Claude Code first, then launch. Peers see you appear on
|
||||
the mesh. Or run plain{" "}
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>{" "}
|
||||
— tools work, but messages are pull-only.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -33,7 +33,8 @@ export const CallToAction = () => {
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Anthropic built Claude Code per developer. The next unlock is
|
||||
between developers. Build the layer with us.
|
||||
between developers. 43 tools, five databases, E2E encryption —
|
||||
open-source and ready now.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
|
||||
@@ -133,10 +133,10 @@ export const DemoDashboard = () => {
|
||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Real conversation between peers. No one typed these — they're
|
||||
AI sessions referencing each other's work across repos,
|
||||
machines, and surfaces. Hover any message to see what the broker
|
||||
sees.
|
||||
Real conversation between peers. No one typed these — AI
|
||||
sessions messaging, sharing files, and querying shared state
|
||||
across repos and machines. Hover any message to see what the
|
||||
broker sees: ciphertext only.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const ITEMS = [
|
||||
},
|
||||
{
|
||||
q: "How do I get started?",
|
||||
a: "One command: `curl -fsSL claudemesh.com/install | bash`. The script checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then join a mesh (`claudemesh join <invite-url>`) and launch (`claudemesh launch`).",
|
||||
a: "Three commands. First: `curl -fsSL https://claudemesh.com/install | bash` — this checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then restart Claude Code. Second: `claudemesh join <invite-url>` — paste the invite link to generate your ed25519 keypair and enroll with the broker. Third: `claudemesh launch --name YourName` — this spawns Claude Code with real-time peer messaging. See the Getting Started guide for full details.",
|
||||
},
|
||||
{
|
||||
q: "Does claudemesh send my code or prompts to the cloud?",
|
||||
@@ -33,7 +33,11 @@ const ITEMS = [
|
||||
},
|
||||
{
|
||||
q: "How is this different from MCP?",
|
||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — so from the agent's point of view, other peers just look like callable tools (send_message, list_peers). It composes on top of MCP; it doesn't replace it.",
|
||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — 43 tools that let peers message, share files, query databases, search vectors, and build graphs together. From the agent's view, other peers look like callable tools. It composes on top of MCP; it doesn't replace it.",
|
||||
},
|
||||
{
|
||||
q: "What persistence backends does the mesh include?",
|
||||
a: "Five. Key-value shared state (instant push on change). Full-text searchable memory (survives across sessions). Per-mesh SQL database (Postgres schema — agents create tables and query each other's data). Vector search (Qdrant — semantic similarity over stored embeddings). Graph database (Neo4j — Cypher queries for relationship modeling). Plus MinIO for E2E encrypted file storage.",
|
||||
},
|
||||
{
|
||||
q: "What stops a malicious peer in my mesh?",
|
||||
|
||||
@@ -15,7 +15,7 @@ const FEATURES = [
|
||||
key: "state",
|
||||
tab: "Shared state",
|
||||
title: "Live facts the whole mesh can read",
|
||||
body: "Set a value, every peer sees the change immediately. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
|
||||
body: "Set a value, every peer sees the change instantly. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
|
||||
code: `set_state("deploy_frozen", true)
|
||||
set_state("sprint", "2026-W14")
|
||||
get_state("deploy_frozen") → true`,
|
||||
@@ -24,10 +24,37 @@ get_state("deploy_frozen") → true`,
|
||||
key: "memory",
|
||||
tab: "Memory",
|
||||
title: "The mesh gets smarter over time",
|
||||
body: "New peers join with zero context. Memory stores institutional knowledge — decisions, incidents, lessons. Full-text searchable. Survives across sessions. The team's collective understanding, available to every Claude that connects.",
|
||||
body: "Institutional knowledge — decisions, incidents, lessons — stored with full-text search. Survives across sessions. New peers join and recall what the team already learned.",
|
||||
code: `remember("Payments API rate-limits at 100 req/s
|
||||
after March incident", tags: ["payments"])
|
||||
recall("rate limit") → ranked results`,
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
tab: "Files",
|
||||
title: "Share artifacts, not copy-paste",
|
||||
body: "Upload a config, a migration script, a test fixture. Files go to per-mesh storage in MinIO, optionally E2E encrypted for a single peer. Grant access later without re-uploading. The mesh tracks who downloaded what.",
|
||||
code: `share_file(path: "./schema.sql", tags: ["migration"])
|
||||
share_file(path: "./creds.json", to: "jordan")
|
||||
grant_file_access(fileId: "abc", to: "sam")`,
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
tab: "Database",
|
||||
title: "A shared SQL database per mesh",
|
||||
body: "Peers create tables, insert rows, and query each other's data — all inside an isolated Postgres schema. One agent tracks bugs, another queries the list. Structured data exchange without file serialization.",
|
||||
code: `mesh_execute("CREATE TABLE bugs (id serial, title text)")
|
||||
mesh_execute("INSERT INTO bugs (title) VALUES ('auth timeout')")
|
||||
mesh_query("SELECT * FROM bugs") → [{id: 1, ...}]`,
|
||||
},
|
||||
{
|
||||
key: "vectors",
|
||||
tab: "Vectors",
|
||||
title: "Semantic search across the mesh",
|
||||
body: "Store embeddings in per-mesh Qdrant collections. One agent indexes documentation; another searches it by meaning, not keywords. The mesh builds a shared knowledge base automatically.",
|
||||
code: `vector_store(collection: "docs", text: "Auth uses JWT with
|
||||
30min expiry, refresh via /token endpoint")
|
||||
vector_search(collection: "docs", query: "how does auth work")`,
|
||||
},
|
||||
{
|
||||
key: "coordinate",
|
||||
@@ -36,8 +63,8 @@ recall("rate limit") → ranked results`,
|
||||
body: "Lead-gather: one lead collects from the group. Chain review: work passes through each member. Delegation: lead assigns subtasks. Voting: members set state, lead tallies. Flood: everyone responds. All through system prompts — no broker code.",
|
||||
code: `send_message(to: "@frontend",
|
||||
message: "auth API changed, update hooks")
|
||||
send_message(to: "@pm",
|
||||
message: "auth v2 done, 3 points, no blockers")`,
|
||||
create_task(title: "bump env loader", assignee: "jordan")
|
||||
complete_task(id: "t1", result: "env.ts updated, PR #42")`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,7 +90,7 @@ export const Features = () => {
|
||||
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
30+ MCP tools. Groups, state, memory, messaging — all shipped.
|
||||
43 MCP tools. Groups, state, memory, files, databases, vectors, streams — all shipped.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
|
||||
@@ -56,8 +56,9 @@ export const Hero = () => {
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code sessions form a team. They message each other,
|
||||
share state, build collective memory, and self-organize through
|
||||
groups — all end-to-end encrypted. One command to launch. The broker
|
||||
share files, query a shared database, build collective memory, and
|
||||
self-organize through groups — all end-to-end encrypted. 43 MCP
|
||||
tools. Five persistence backends. One command to launch. The broker
|
||||
routes ciphertext; it never reads your messages.
|
||||
<span className="block pt-2 text-[var(--cm-clay)]">
|
||||
Open-source CLI. Free during public beta.
|
||||
@@ -94,10 +95,10 @@ export const Hero = () => {
|
||||
>
|
||||
Or{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli#readme"
|
||||
href="/getting-started"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||
>
|
||||
read the documentation
|
||||
read the getting started guide
|
||||
</Link>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
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";
|
||||
|
||||
const SHIPPING = [
|
||||
"CLI + MCP server (Claude Code integration)",
|
||||
"CLI + 43 MCP tools (Claude Code integration)",
|
||||
"Hosted broker on claudemesh.com",
|
||||
"End-to-end encrypted direct messages (crypto_box)",
|
||||
"E2E encrypted messaging + file sharing",
|
||||
"Priority routing (now / next / low)",
|
||||
"Mesh invites + membership",
|
||||
"Windows, macOS, Linux support",
|
||||
"Shared state, memory, tasks, and streams",
|
||||
"Per-mesh SQL database, vector search, and graph DB",
|
||||
"Scheduled messages and reminders",
|
||||
"Mesh invites + ed25519 identity",
|
||||
];
|
||||
|
||||
const ROADMAP = [
|
||||
|
||||
@@ -322,10 +322,11 @@ export const WhatIsClaudemesh = () => {
|
||||
className="text-[16px] leading-[1.65] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh of Claudes. Each keeps its own repo, memory, history.
|
||||
They reference each other on demand. Your identity travels
|
||||
across surfaces. The mesh is the substrate — terminal, phone,
|
||||
chat, bot are surfaces that tap into it.
|
||||
A mesh of Claudes. Each keeps its own repo and context.
|
||||
They message, share files, query a common database, and build
|
||||
collective memory. Your identity travels across surfaces.
|
||||
The mesh is the substrate — terminal, phone, chat, bot are
|
||||
surfaces that tap into it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,10 +458,11 @@ export const WhatIsClaudemesh = () => {
|
||||
className="border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh adds a secure wire and a shared identity between the AI
|
||||
sessions you already run. Your Claudes stay specialized — each
|
||||
knows its own repo. The mesh lets them reference each other's
|
||||
work when useful. The human coordinates once, instead of N times.
|
||||
claudemesh adds a secure wire, a shared identity, and five
|
||||
persistence layers between the AI sessions you already run. Your
|
||||
Claudes stay specialized — each knows its own repo. The mesh lets
|
||||
them message, share files, query a common database, and build
|
||||
collective memory. The human coordinates once, instead of N times.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const columns = [
|
||||
{
|
||||
label: "product",
|
||||
items: [
|
||||
{ title: "Getting Started", href: pathsConfig.marketing.gettingStarted },
|
||||
{ title: "Docs", href: "#docs" },
|
||||
{ title: "Pricing", href: pathsConfig.marketing.pricing },
|
||||
{ title: "Changelog", href: "#changelog" },
|
||||
@@ -75,8 +76,8 @@ export const Footer = () => {
|
||||
className="text-sm leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Peer mesh for Claude Code. Every session, woven into one mesh —
|
||||
reachable from anywhere you are.
|
||||
Peer mesh for Claude Code. Messaging, files, databases, vectors,
|
||||
graphs — E2E encrypted. Every session, woven into one mesh.
|
||||
</p>
|
||||
<I18nControls />
|
||||
<div className="mt-2 flex items-center gap-2.5">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const NAV = [
|
||||
{ label: "Getting Started", href: "/getting-started" },
|
||||
{ label: "Docs", href: "#docs" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Changelog", href: "#changelog" },
|
||||
|
||||
138
apps/web/src/modules/mesh/peer-graph-panel.tsx
Normal file
138
apps/web/src/modules/mesh/peer-graph-panel.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import {
|
||||
PeerGraph,
|
||||
type GraphPeer,
|
||||
type GraphEdge,
|
||||
} from "~/modules/mesh/peer-graph";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transform broker response into graph-friendly structures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildGraphData = (data: GetMyMeshStreamResponse) => {
|
||||
// Count messages per sender
|
||||
const countMap = new Map<string, number>();
|
||||
for (const e of data.envelopes) {
|
||||
countMap.set(e.senderMemberId, (countMap.get(e.senderMemberId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const peers: GraphPeer[] = data.presences.map((p) => ({
|
||||
id: p.memberId,
|
||||
name: p.displayName ?? p.memberId.slice(0, 8),
|
||||
status: p.status === "dnd" ? "dnd" : p.status,
|
||||
messageCount: countMap.get(p.memberId) ?? 0,
|
||||
}));
|
||||
|
||||
const edges: GraphEdge[] = data.envelopes.map((e) => ({
|
||||
key: e.id,
|
||||
from: e.senderMemberId,
|
||||
to: e.targetSpec === "*" ? null : e.targetSpec,
|
||||
priority: e.priority,
|
||||
createdAt: new Date(e.createdAt),
|
||||
}));
|
||||
|
||||
return { peers, edges };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Panel component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const PeerGraphPanel = ({ meshId }: { meshId: string }) => {
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const { peers, edges } = useMemo(
|
||||
() => (data ? buildGraphData(data) : { peers: [], edges: [] }),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
peer graph
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{peers.length} peers ·{" "}
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Graph area */}
|
||||
<div className="relative aspect-square w-full min-h-[320px]">
|
||||
<PeerGraph peers={peers} edges={edges} />
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
idle
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
|
||||
working
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#c46686]" />
|
||||
dnd
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--cm-border)]">|</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-emerald-500" />
|
||||
low
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-[var(--cm-fg-secondary)]" />
|
||||
next
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-red-500" />
|
||||
now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
462
apps/web/src/modules/mesh/peer-graph.tsx
Normal file
462
apps/web/src/modules/mesh/peer-graph.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PeerStatus } from "~/modules/marketing/home/mesh-stream";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface GraphPeer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PeerStatus;
|
||||
summary?: string;
|
||||
/** Number of messages sent by this peer — drives node sizing */
|
||||
messageCount: number;
|
||||
/** Group names this peer belongs to */
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
key: string;
|
||||
from: string;
|
||||
to: string | null; // null = broadcast (draw to all)
|
||||
priority: "now" | "next" | "low";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PeerGraphProps {
|
||||
peers: GraphPeer[];
|
||||
edges: GraphEdge[];
|
||||
meshName?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<PeerStatus, string> = {
|
||||
idle: "#22c55e", // emerald-500
|
||||
working: "#d97757", // --cm-clay
|
||||
dnd: "#c46686", // --cm-fig
|
||||
offline: "#87867f", // --cm-fg-tertiary
|
||||
};
|
||||
|
||||
const PRIORITY_COLOR: Record<string, string> = {
|
||||
low: "#22c55e",
|
||||
next: "#c2c0b6",
|
||||
now: "#ef4444",
|
||||
};
|
||||
|
||||
/** How long edges remain visible (ms) */
|
||||
const EDGE_TTL_MS = 8_000;
|
||||
|
||||
/** Ring colors for groups — up to 8 distinct groups */
|
||||
const GROUP_RING_COLORS = [
|
||||
"#d97757", // clay
|
||||
"#c46686", // fig
|
||||
"#bcd1ca", // cactus
|
||||
"#e3dacc", // oat
|
||||
"#6ea8fe", // blue
|
||||
"#fbbf24", // amber
|
||||
"#a78bfa", // violet
|
||||
"#f472b6", // pink
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Radial layout: peers on a circle, center reserved for mesh label. */
|
||||
const computeLayout = (
|
||||
peerCount: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) => {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const radius = Math.min(cx, cy) * 0.68;
|
||||
return { cx, cy, radius };
|
||||
};
|
||||
|
||||
const peerPosition = (
|
||||
index: number,
|
||||
total: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
) => {
|
||||
const angle = (index / total) * 2 * Math.PI - Math.PI / 2; // start at top
|
||||
return {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
/** Scale node radius based on message volume relative to peers. */
|
||||
const nodeRadius = (count: number, maxCount: number) => {
|
||||
const base = 22;
|
||||
const extra = 12;
|
||||
if (maxCount === 0) return base;
|
||||
return base + (count / maxCount) * extra;
|
||||
};
|
||||
|
||||
/** Build a group-color map from all peers. */
|
||||
const buildGroupColorMap = (peers: GraphPeer[]) => {
|
||||
const seen = new Set<string>();
|
||||
for (const p of peers) {
|
||||
for (const g of p.groups ?? []) seen.add(g);
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
let i = 0;
|
||||
for (const g of seen) {
|
||||
map.set(g, GROUP_RING_COLORS[i % GROUP_RING_COLORS.length]!);
|
||||
i++;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/** Quadratic bezier control point offset for curved edges */
|
||||
const curveOffset = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
) => {
|
||||
// Push the control point toward center for a slight curve
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
const factor = 0.15;
|
||||
return {
|
||||
qx: mx + (cx - mx) * factor,
|
||||
qy: my + (cy - my) * factor,
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const PeerGraph = ({ peers, edges, meshName }: PeerGraphProps) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 520, height: 520 });
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// Tick every second to fade edges
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Responsive resize
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) setDimensions({ width, height });
|
||||
});
|
||||
ro.observe(svg);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const { width, height } = dimensions;
|
||||
const { cx, cy, radius } = computeLayout(peers.length, width, height);
|
||||
const maxCount = useMemo(
|
||||
() => Math.max(1, ...peers.map((p) => p.messageCount)),
|
||||
[peers],
|
||||
);
|
||||
const groupColorMap = useMemo(() => buildGroupColorMap(peers), [peers]);
|
||||
|
||||
// Map peer id -> position
|
||||
const posMap = useMemo(() => {
|
||||
const m = new Map<string, { x: number; y: number }>();
|
||||
peers.forEach((p, i) => {
|
||||
m.set(p.id, peerPosition(i, peers.length, cx, cy, radius));
|
||||
});
|
||||
return m;
|
||||
}, [peers, cx, cy, radius]);
|
||||
|
||||
// Filter edges to those still visible
|
||||
const visibleEdges = useMemo(
|
||||
() => edges.filter((e) => now - e.createdAt.getTime() < EDGE_TTL_MS),
|
||||
[edges, now],
|
||||
);
|
||||
|
||||
// Build edge paths: direct -> single path, broadcast -> one path per peer
|
||||
const edgePaths = useMemo(() => {
|
||||
const paths: {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}[] = [];
|
||||
|
||||
for (const e of visibleEdges) {
|
||||
const fromPos = posMap.get(e.from);
|
||||
if (!fromPos) continue;
|
||||
const age = now - e.createdAt.getTime();
|
||||
const opacity = Math.max(0, 1 - age / EDGE_TTL_MS);
|
||||
const color = PRIORITY_COLOR[e.priority] ?? PRIORITY_COLOR.next!;
|
||||
|
||||
if (e.to === null || e.to === "*") {
|
||||
// Broadcast: lines to all other peers
|
||||
for (const [pid, pos] of posMap) {
|
||||
if (pid === e.from) continue;
|
||||
const { qx, qy } = curveOffset(
|
||||
fromPos.x,
|
||||
fromPos.y,
|
||||
pos.x,
|
||||
pos.y,
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
paths.push({
|
||||
key: `${e.key}-${pid}`,
|
||||
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${pos.x},${pos.y}`,
|
||||
color,
|
||||
opacity: opacity * 0.6,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const toPos = posMap.get(e.to);
|
||||
if (!toPos) continue;
|
||||
const { qx, qy } = curveOffset(
|
||||
fromPos.x,
|
||||
fromPos.y,
|
||||
toPos.x,
|
||||
toPos.y,
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
paths.push({
|
||||
key: e.key,
|
||||
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${toPos.x},${toPos.y}`,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}, [visibleEdges, posMap, cx, cy, now]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="h-full w-full"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={`Peer graph for mesh${meshName ? ` "${meshName}"` : ""} showing ${peers.length} peers and recent message traffic`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{/* Subtle radial grid */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 6"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius * 0.5}
|
||||
fill="none"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="2 4"
|
||||
opacity="0.2"
|
||||
/>
|
||||
|
||||
{/* Center mesh label */}
|
||||
{meshName && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="11"
|
||||
opacity="0.5"
|
||||
>
|
||||
{meshName}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Edges */}
|
||||
<g>
|
||||
{edgePaths.map((ep) => (
|
||||
<path
|
||||
key={ep.key}
|
||||
d={ep.d}
|
||||
fill="none"
|
||||
stroke={ep.color}
|
||||
strokeWidth="1.5"
|
||||
opacity={ep.opacity}
|
||||
style={{
|
||||
transition: "opacity 1s ease-out",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Animated pulse dots traveling along edges */}
|
||||
{edgePaths
|
||||
.filter((ep) => ep.opacity > 0.3)
|
||||
.map((ep) => (
|
||||
<circle key={`dot-${ep.key}`} r="2.5" fill={ep.color} opacity={ep.opacity}>
|
||||
<animateMotion
|
||||
dur="1.2s"
|
||||
repeatCount="1"
|
||||
path={ep.d}
|
||||
fill="freeze"
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* Peer nodes */}
|
||||
{peers.map((peer, i) => {
|
||||
const pos = posMap.get(peer.id);
|
||||
if (!pos) return null;
|
||||
const r = nodeRadius(peer.messageCount, maxCount);
|
||||
const groups = peer.groups ?? [];
|
||||
|
||||
return (
|
||||
<g key={peer.id}>
|
||||
{/* Group rings (concentric, outermost first) */}
|
||||
{groups.map((g, gi) => {
|
||||
const ringR = r + 5 + gi * 4;
|
||||
const ringColor = groupColorMap.get(g) ?? GROUP_RING_COLORS[0]!;
|
||||
return (
|
||||
<circle
|
||||
key={g}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={ringR}
|
||||
fill="none"
|
||||
stroke={ringColor}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 3"
|
||||
opacity="0.55"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer glow for active status */}
|
||||
{peer.status === "working" && (
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={r + 2}
|
||||
fill="none"
|
||||
stroke={STATUS_COLOR.working}
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values={`${r + 2};${r + 6};${r + 2}`}
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.3;0.08;0.3"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
)}
|
||||
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={r}
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke={STATUS_COLOR[peer.status]}
|
||||
strokeWidth="2"
|
||||
style={{ transition: "all 0.6s var(--cm-ease)" }}
|
||||
/>
|
||||
|
||||
{/* Status indicator dot */}
|
||||
<circle
|
||||
cx={pos.x + r * 0.6}
|
||||
cy={pos.y - r * 0.6}
|
||||
r="4"
|
||||
fill={STATUS_COLOR[peer.status]}
|
||||
stroke="var(--cm-bg)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Initials inside node */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
>
|
||||
{peer.name.slice(0, 2).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Name label below */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + r + 14}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-secondary)"
|
||||
fontSize="10"
|
||||
>
|
||||
{peer.name.length > 12
|
||||
? peer.name.slice(0, 11) + "\u2026"
|
||||
: peer.name}
|
||||
</text>
|
||||
|
||||
{/* Truncated summary below name */}
|
||||
{peer.summary && (
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + r + 26}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="8"
|
||||
>
|
||||
{peer.summary.length > 24
|
||||
? peer.summary.slice(0, 23) + "\u2026"
|
||||
: peer.summary}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{peers.length === 0 && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="12"
|
||||
>
|
||||
No peers connected
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ResourceCard {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: { id: string; text: string; sub: string }[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build resource cards from stream data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildResources = (data: GetMyMeshStreamResponse): ResourceCard[] => {
|
||||
const onlinePeers = data.presences.filter((p) => !p.disconnectedAt);
|
||||
const offlinePeers = data.presences.filter((p) => p.disconnectedAt);
|
||||
|
||||
const priorityCounts = { now: 0, next: 0, low: 0 };
|
||||
for (const e of data.envelopes) {
|
||||
priorityCounts[e.priority] = (priorityCounts[e.priority] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Unique senders
|
||||
const uniqueSenders = new Set(data.envelopes.map((e) => e.senderMemberId));
|
||||
|
||||
// Recent audit event types
|
||||
const eventTypes = new Map<string, number>();
|
||||
for (const e of data.auditEvents) {
|
||||
eventTypes.set(e.eventType, (eventTypes.get(e.eventType) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "peers",
|
||||
icon: "⬡",
|
||||
label: "Live Peers",
|
||||
count: onlinePeers.length,
|
||||
accent: "text-emerald-500",
|
||||
items: onlinePeers.slice(0, 4).map((p) => ({
|
||||
id: p.id,
|
||||
text: p.displayName ?? p.memberId.slice(0, 8),
|
||||
sub: `${p.status} · ${p.cwd.split("/").pop() ?? p.cwd}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "envelopes",
|
||||
icon: "▤",
|
||||
label: "Envelopes",
|
||||
count: data.envelopes.length,
|
||||
accent: "text-[var(--cm-clay)]",
|
||||
items: [
|
||||
{
|
||||
id: "priority-now",
|
||||
text: `${priorityCounts.now} now`,
|
||||
sub: "urgent / bypass busy",
|
||||
},
|
||||
{
|
||||
id: "priority-next",
|
||||
text: `${priorityCounts.next} next`,
|
||||
sub: "default priority",
|
||||
},
|
||||
{
|
||||
id: "priority-low",
|
||||
text: `${priorityCounts.low} low`,
|
||||
sub: "pull-only",
|
||||
},
|
||||
{
|
||||
id: "senders",
|
||||
text: `${uniqueSenders.size} unique senders`,
|
||||
sub: "across all envelopes",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
icon: "◈",
|
||||
label: "Audit Events",
|
||||
count: data.auditEvents.length,
|
||||
accent: "text-[#c46686]",
|
||||
items: Array.from(eventTypes.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([type, count]) => ({
|
||||
id: `evt-${type}`,
|
||||
text: type.replace(/_/g, " "),
|
||||
sub: `${count} occurrence${count !== 1 ? "s" : ""}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
icon: "⊡",
|
||||
label: "Sessions",
|
||||
count: data.presences.length,
|
||||
accent: "text-[var(--cm-fg-secondary)]",
|
||||
items: [
|
||||
{
|
||||
id: "online",
|
||||
text: `${onlinePeers.length} online`,
|
||||
sub: "currently connected",
|
||||
},
|
||||
{
|
||||
id: "offline",
|
||||
text: `${offlinePeers.length} offline`,
|
||||
sub: "recently disconnected",
|
||||
},
|
||||
...data.presences
|
||||
.filter((p) => p.status === "working")
|
||||
.slice(0, 2)
|
||||
.map((p) => ({
|
||||
id: `working-${p.id}`,
|
||||
text: `${p.displayName ?? p.memberId.slice(0, 8)}`,
|
||||
sub: "currently working",
|
||||
})),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ResourcePanel = ({ meshId }: { meshId: string }) => {
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const resources = useMemo(
|
||||
() => (data ? buildResources(data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
resources
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Resource cards grid */}
|
||||
<div
|
||||
className="grid grid-cols-2 gap-px bg-[var(--cm-border)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{resources.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className="flex flex-col bg-[var(--cm-bg)] p-3"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] ${card.accent}`}>
|
||||
{card.icon}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-lg font-semibold leading-none tabular-nums ${card.accent}`}>
|
||||
{card.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Recent items */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{card.items.length === 0 ? (
|
||||
<span className="text-[9px] text-[var(--cm-fg-tertiary)]">
|
||||
none
|
||||
</span>
|
||||
) : (
|
||||
card.items.map((item) => (
|
||||
<div key={item.id} className="min-w-0">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-[var(--cm-fg-secondary)] truncate">
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--cm-fg-tertiary)] truncate">
|
||||
{item.sub}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span>derived from stream data</span>
|
||||
<span>read-only snapshot</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal file
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
type: "audit" | "presence" | "envelope";
|
||||
icon: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
actor: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build timeline from stream data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
peer_connected: "connected",
|
||||
peer_disconnected: "disconnected",
|
||||
message_sent: "msg sent",
|
||||
message_delivered: "msg delivered",
|
||||
invite_created: "invite created",
|
||||
invite_redeemed: "invite redeemed",
|
||||
member_joined: "member joined",
|
||||
member_removed: "member removed",
|
||||
state_changed: "state changed",
|
||||
};
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
peer_connected: "↑",
|
||||
peer_disconnected: "↓",
|
||||
message_sent: "→",
|
||||
message_delivered: "✓",
|
||||
invite_created: "✉",
|
||||
invite_redeemed: "★",
|
||||
member_joined: "+",
|
||||
member_removed: "−",
|
||||
state_changed: "Δ",
|
||||
};
|
||||
|
||||
const buildTimeline = (data: GetMyMeshStreamResponse): TimelineEntry[] => {
|
||||
const entries: TimelineEntry[] = [];
|
||||
|
||||
// Audit events → timeline entries
|
||||
for (const e of data.auditEvents) {
|
||||
entries.push({
|
||||
id: e.id,
|
||||
timestamp: new Date(e.createdAt),
|
||||
type: "audit",
|
||||
icon: EVENT_ICONS[e.eventType] ?? "•",
|
||||
label: EVENT_LABELS[e.eventType] ?? e.eventType.replace(/_/g, " "),
|
||||
detail: [
|
||||
e.actorPeerId ? `actor:${e.actorPeerId.slice(0, 8)}` : null,
|
||||
e.targetPeerId ? `target:${e.targetPeerId.slice(0, 8)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" → ") || "—",
|
||||
actor: e.actorPeerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Presence status snapshots → timeline entries (latest status per peer)
|
||||
for (const p of data.presences) {
|
||||
entries.push({
|
||||
id: `presence-${p.id}`,
|
||||
timestamp: new Date(p.statusUpdatedAt),
|
||||
type: "presence",
|
||||
icon: p.status === "idle" ? "◇" : p.status === "working" ? "◆" : "◈",
|
||||
label: `${p.displayName ?? p.memberId.slice(0, 8)} → ${p.status}`,
|
||||
detail: `via ${p.statusSource} · pid ${p.pid}`,
|
||||
actor: p.memberId,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort descending (newest first)
|
||||
entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Format helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const fmtTime = (d: Date) =>
|
||||
d.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const TYPE_COLORS: Record<TimelineEntry["type"], string> = {
|
||||
audit: "text-[var(--cm-clay)]",
|
||||
presence: "text-emerald-500",
|
||||
envelope: "text-[#c46686]",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const StateTimelinePanel = ({ meshId }: { meshId: string }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const entries = useMemo(
|
||||
() => (data ? buildTimeline(data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
// Auto-scroll to top (newest) on new data
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [entries.length]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
event timeline
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{entries.length} events ·{" "}
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline body */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[420px] overflow-y-auto scrollbar-thin"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||
No events recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative px-4 py-3">
|
||||
{/* Vertical spine */}
|
||||
<div className="absolute left-[27px] top-3 bottom-3 w-px bg-[var(--cm-border)]" />
|
||||
|
||||
{entries.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="group relative flex items-start gap-3 py-1.5"
|
||||
>
|
||||
{/* Node dot */}
|
||||
<div className="relative z-10 flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={`text-[10px] leading-none ${TYPE_COLORS[entry.type]}`}
|
||||
>
|
||||
{entry.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)] tabular-nums">
|
||||
{fmtTime(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium ${TYPE_COLORS[entry.type]}`}>
|
||||
{entry.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-[var(--cm-fg-tertiary)] truncate">
|
||||
{entry.detail}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<span className="flex-shrink-0 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[8px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[var(--cm-clay)]">•</span>
|
||||
audit
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-emerald-500">•</span>
|
||||
presence
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[#c46686]">•</span>
|
||||
envelope
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--cm-border)]">|</span>
|
||||
<span>newest first · auto-scroll</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
164
docs/changelog-20260407.md
Normal file
164
docs/changelog-20260407.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# claudemesh — Implementation Changelog
|
||||
|
||||
**Sprint:** 2026-04-07 evening session
|
||||
**Author:** Alejandro Gutiérrez + Claude (Opus 4.6)
|
||||
**CLI versions:** 0.6.8 → 0.6.9 → 0.7.0
|
||||
**Broker:** deployed to `ic.claudemesh.com` (Coolify, OVHcloud VPS)
|
||||
|
||||
---
|
||||
|
||||
## Features shipped
|
||||
|
||||
### 1. Session path (cwd) sharing
|
||||
`810f372` · CLI 0.6.9 + broker
|
||||
|
||||
Added `cwd` to the WS hello handshake. Broker stores it in the peer record, `list_peers` returns it. Peers on the same machine see each other's working directories for direct file referencing.
|
||||
|
||||
### 2. Peer metadata (type, channel, model)
|
||||
`810f372` · Same commit as cwd
|
||||
|
||||
Extended hello with `peerType: "ai" | "human" | "connector"`, `channel` (e.g. "claude-code", "telegram"), `model` (e.g. "opus-4"). Foundation for connectors, humans, and smart routing.
|
||||
|
||||
### 3. System notifications (peer join/leave)
|
||||
`453705a` · broker + CLI
|
||||
|
||||
Broker broadcasts `{ subtype: "system", event: "peer_joined" | "peer_left" }` pushes to all mesh peers on connect/disconnect. MCP server formats them as `[system] Peer "Alice" joined the mesh`. System events bypass inbox/off message modes.
|
||||
|
||||
### 4. Cron-based persistent reminders
|
||||
`e873807` · broker + CLI + `72be651` (--cron flag)
|
||||
|
||||
Replaced in-memory `setTimeout` with DB-persisted scheduler. Zero-dependency 5-field cron parser. Schedules survive broker restarts via `recoverScheduledMessages()` on boot. CLI: `claudemesh remind "check deploys" --cron "0 */2 * * *"`. MCP: `schedule_reminder` with `cron` field.
|
||||
|
||||
### 5. Simulation clock with time multiplier
|
||||
`05d9b56` · broker + CLI
|
||||
|
||||
Per-mesh clock state (`MeshClock` interface + `meshClocks` Map). Configurable speed x1–x100. Broadcasts heartbeat ticks as system pushes: `{ event: "tick", eventData: { tick, simTime, speed } }`. Auto-pauses when last peer disconnects. MCP tools: `mesh_set_clock`, `mesh_pause_clock`, `mesh_resume_clock`, `mesh_clock`.
|
||||
|
||||
### 6. Inbound webhooks
|
||||
`b55cf26` · broker (new `webhooks.ts`) + CLI
|
||||
|
||||
`POST /hook/:meshId/:secret` → broker injects as push to all mesh peers. Webhooks stored in `meshWebhook` Drizzle table. MCP tools: `create_webhook` (returns URL+secret), `list_webhooks`, `delete_webhook`. Push format: `{ subtype: "webhook", event: "webhook_name", eventData: {...body} }`.
|
||||
|
||||
### 7. Slack connector
|
||||
`5563f90` · `packages/connector-slack/`
|
||||
|
||||
Bridge process using `@slack/socket-mode` + `@slack/web-api`. Joins mesh as `peerType: "connector"`, `channel: "slack"`. Bidirectional relay with echo prevention, user ID-to-name resolution with caching, auto-reconnect with exponential backoff.
|
||||
|
||||
### 8. Telegram connector
|
||||
`fe92853` · `packages/connector-telegram/`
|
||||
|
||||
Zero-dependency Telegram Bot API client using native `fetch` + long polling. Same bridge pattern as Slack. HTML formatting for Telegram output. Auto-reconnect with exponential backoff (1s–30s).
|
||||
|
||||
### 9. Non-Claude-Code SDK
|
||||
`7e102a2` · `packages/sdk/`
|
||||
|
||||
Standalone TypeScript SDK (`@claudemesh/sdk`). `MeshClient extends EventEmitter` with `connect()`, `send()`, `broadcast()`, `listPeers()`, `getState()`, `setState()`. Uses `libsodium-wrappers` for ed25519-to-curve25519 crypto_box encryption (same as CLI). Auto-reconnect with exponential backoff.
|
||||
|
||||
### 10. Mesh skills catalog
|
||||
`c8cb1e3` · broker (Drizzle schema + handlers) + CLI
|
||||
|
||||
Peers publish reusable skills (name, description, instructions, tags). Full CRUD: `share_skill` (upsert by name), `get_skill`, `list_skills` (ILIKE search), `remove_skill`. Stored in `meshSkill` table with unique (meshId, name). `get_skill` returns instructions prominently formatted for immediate AI use.
|
||||
|
||||
### 11. Shared project files
|
||||
`504111c` · broker relay + CLI file serving
|
||||
|
||||
Peer-to-peer file relay: `read_peer_file(peer, path)` and `list_peer_files(peer, path?, pattern?)`. Broker relays without reading content. Security: 1MB max, path traversal rejection, hidden files excluded, 2-level dir listing cap (500 entries). Plus hostname-based local/remote detection (`2c9c8c7`) and filesystem shortcut hint for local peers (`a92cf6b`).
|
||||
|
||||
### 12. Peer stats reporting
|
||||
`b3b9972` · broker + CLI
|
||||
|
||||
Peers auto-report stats every 60s: messagesIn/Out, toolCalls, uptime, errors. `set_stats` WS message + `mesh_stats` MCP tool. Stats visible in `list_peers` response. Tool call counter incremented on every MCP invocation.
|
||||
|
||||
### 13. Signed audit log (hash chain)
|
||||
`86a2583` · broker (new `audit.ts` + Drizzle schema)
|
||||
|
||||
SHA-256 hash-chained append-only log. Each entry hashes: `prevHash|meshId|eventType|actorMemberId|payload|createdAt`. Events logged: peer_joined, peer_left, state_set, message_sent (NO ciphertext). WS endpoints: `audit_query` (paginated), `audit_verify` (chain integrity check). On startup: `ensureAuditLogTable()` + `loadLastHashes()`.
|
||||
|
||||
### 14. Mesh templates
|
||||
`69e93d4` · CLI (`apps/cli/src/templates/`)
|
||||
|
||||
5 JSON templates: dev-team, research, ops-incident, simulation, personal. Each defines groups, roles, state keys, and a system prompt hint. `claudemesh create --template dev-team` loads and displays template. `claudemesh create --list-templates` shows all.
|
||||
|
||||
### 15. Default personal mesh guidance
|
||||
`b0dc538` · CLI (`install.ts`)
|
||||
|
||||
`claudemesh install` detects empty meshes and shows join guidance. Local-only mesh deferred (requires broker enrollment for real connectivity).
|
||||
|
||||
### 16. Mesh MCP proxy
|
||||
`08e289a` · broker + CLI
|
||||
|
||||
Dynamic tool sharing: `mesh_mcp_register` → `mesh_mcp_list` → `mesh_tool_call` → broker forwards to hosting peer → execute → result back. In-memory registry with 30s call timeout. Auto-cleanup on disconnect. MCP register/unregister broadcasts system notifications (`e09671c`).
|
||||
|
||||
### 17. Dashboard: peer graph + state timeline + resource panel
|
||||
`59332dc` (peer graph) + `7d432b3` (timeline + resources)
|
||||
|
||||
**Peer graph:** Radial SVG layout, animated bezier edges with priority colors, group rings, status indicators (green/amber/red), node sizing by activity. No external deps (pure SVG + CSS animations). `ResizeObserver` for responsive sizing.
|
||||
|
||||
**State timeline:** Vertical timeline of audit events with timestamps, icons, type badges. Newest-first with auto-scroll. Shares same TanStack Query cache (zero extra API calls).
|
||||
|
||||
**Resource panel:** 2x2 card grid — live peers, envelope breakdown, audit event frequency, session online/offline split.
|
||||
|
||||
### 18. Peer visibility + public profiles
|
||||
Broker types.ts + index.ts + CLI
|
||||
|
||||
`set_visible(false)` makes peer invisible in `list_peers` and skips broadcast/group routing. Direct messages by pubkey still reach hidden peers. System events: `peer_visible`, `peer_hidden`. Public profiles: `set_profile({ avatar, title, bio, capabilities })` — visible to other peers in `list_peers` and peer graph.
|
||||
|
||||
### 19. Hostname + local/remote detection
|
||||
`2c9c8c7` · broker + CLI
|
||||
|
||||
`os.hostname()` added to hello handshake. `list_peers` shows `[local]` or `[remote]` tag per peer. MCP instructions include file access decision guide: local → filesystem, remote <1MB → `read_peer_file`, large/persistent → `share_file`.
|
||||
|
||||
### 20. File access decision guide in MCP instructions
|
||||
`3641618` · CLI MCP server
|
||||
|
||||
Clear decision guide in system instructions: three methods (filesystem for local, relay for remote, MinIO for persistent), with size limits and when to use each.
|
||||
|
||||
### 21. MCP server register/unregister broadcasts
|
||||
`e09671c` · broker + CLI
|
||||
|
||||
When a peer registers or removes an MCP server, all mesh peers receive a system notification: `[system] New MCP server available: "github" (hosted by Alice). Tools: list_repos, create_issue. Use mesh_tool_call to invoke.`
|
||||
|
||||
---
|
||||
|
||||
## Also shipped (infrastructure / docs)
|
||||
|
||||
| Commit | What |
|
||||
|--------|------|
|
||||
| `0bb9d71` | Merged `schedule_reminder` + `send_later` into single tool with optional `to` param; added `subtype: "reminder"` to push |
|
||||
| `79525af` | Fixed TSC error from cron example in JSDoc comment |
|
||||
| `69e93d4` | Mesh templates: 5 JSON templates + `claudemesh create` command |
|
||||
| `f34b8fb` | CLI `--help` text review: 44 descriptions improved for clarity, concision, consistency |
|
||||
| `58ba01f` | `CLAUDEMESH_TOOLS` in install.ts synced (41→45 tools, sorted alphabetically) |
|
||||
| `db2bf3e` | `protocol.md` expanded from 6 to 73 message types |
|
||||
| `72be651` | `--cron` flag wired into citty remind command |
|
||||
|
||||
---
|
||||
|
||||
## CLI versions published
|
||||
|
||||
| Version | Key changes |
|
||||
|---------|------------|
|
||||
| 0.6.8 | schedule_reminder merge, reminder subtype |
|
||||
| 0.6.9 | cwd + peer metadata + system notifications + cron + templates + --help review |
|
||||
| 0.7.0 | Skills catalog, MCP proxy, shared files, visibility, sim clock, webhooks, peer stats, connectors, SDK |
|
||||
|
||||
---
|
||||
|
||||
## Pending (building)
|
||||
|
||||
- **Peer session persistence** — agent running, DB-backed state restore on reconnect
|
||||
- **Persistent MCP registrations** — agent running, survive peer disconnect with online/offline status
|
||||
|
||||
---
|
||||
|
||||
## Remaining from vision (not yet built)
|
||||
|
||||
| # | Feature | Notes |
|
||||
|---|---------|-------|
|
||||
| 6 | REST API + external WS | Webhooks done, REST and WS auth remain |
|
||||
| 8 | Humans in the mesh | Web chat panel needed |
|
||||
| 14 | Bridge / federation | Bridge peer feasible now, federation needs design |
|
||||
| 18 | Sandboxes (E2B) | Third-party integration preferred |
|
||||
| 20 | Spatial topology (x,y proximity) | Visibility done, proximity model remains |
|
||||
| 21 | Semantic peer search | Multi-field matching, half day |
|
||||
| 22 | Mesh telemetry + debugging | Structured logging + reporting |
|
||||
306
docs/protocol.md
306
docs/protocol.md
@@ -15,14 +15,86 @@ leaves the peer.
|
||||
|
||||
All broker ↔ peer traffic is line-delimited JSON on a single WebSocket.
|
||||
|
||||
| Type | Direction | Purpose |
|
||||
|--------------|---------------|----------------------------------------------------|
|
||||
| `hello` | peer → broker | signed handshake — proves control of ed25519 key |
|
||||
| `hello_ack` | broker → peer | confirms identity + returns current mesh presence |
|
||||
| `send` | peer → broker | ciphertext envelope addressed to one or more peers |
|
||||
| `ack` | broker → peer | broker-side delivery receipt for a `send` |
|
||||
| `push` | broker → peer | an inbound envelope the broker is forwarding |
|
||||
| `error` | broker → peer | handshake or authorization failure |
|
||||
| Type | Direction | Purpose |
|
||||
|------------------------|---------------|----------------------------------------------------|
|
||||
| `hello` | peer → broker | signed handshake — proves control of ed25519 key |
|
||||
| `hello_ack` | broker → peer | confirms identity + returns current mesh presence |
|
||||
| `send` | peer → broker | ciphertext envelope addressed to one or more peers |
|
||||
| `ack` | broker → peer | broker-side delivery receipt for a `send` |
|
||||
| `push` | broker → peer | an inbound envelope the broker is forwarding |
|
||||
| `set_status` | peer → broker | manual status override (idle, working, dnd) |
|
||||
| `set_summary` | peer → broker | update the session's human-readable summary |
|
||||
| `list_peers` | peer → broker | request connected peers in the same mesh |
|
||||
| `peers_list` | broker → peer | response to `list_peers` |
|
||||
| `join_group` | peer → broker | join a named group with optional role |
|
||||
| `leave_group` | peer → broker | leave a named group |
|
||||
| `set_state` | peer → broker | write a shared key-value pair |
|
||||
| `get_state` | peer → broker | read a shared state key |
|
||||
| `list_state` | peer → broker | list all shared state entries |
|
||||
| `state_change` | broker → peer | a state key was changed by another peer |
|
||||
| `state_result` | broker → peer | response to `get_state` |
|
||||
| `state_list` | broker → peer | response to `list_state` |
|
||||
| `remember` | peer → broker | store a persistent memory |
|
||||
| `recall` | peer → broker | full-text search over memories |
|
||||
| `forget` | peer → broker | soft-delete a memory |
|
||||
| `memory_stored` | broker → peer | acknowledgement for `remember` |
|
||||
| `memory_results` | broker → peer | response to `recall` |
|
||||
| `message_status` | peer → broker | check delivery status of a sent message |
|
||||
| `message_status_result`| broker → peer | per-recipient delivery detail |
|
||||
| `share_context` | peer → broker | share current working context |
|
||||
| `get_context` | peer → broker | search shared contexts by query |
|
||||
| `list_contexts` | peer → broker | list all shared contexts |
|
||||
| `context_shared` | broker → peer | acknowledgement for `share_context` |
|
||||
| `context_results` | broker → peer | response to `get_context` |
|
||||
| `context_list` | broker → peer | response to `list_contexts` |
|
||||
| `create_task` | peer → broker | create a task |
|
||||
| `claim_task` | peer → broker | claim an open task |
|
||||
| `complete_task` | peer → broker | mark a task as done |
|
||||
| `list_tasks` | peer → broker | list tasks with optional filters |
|
||||
| `task_created` | broker → peer | acknowledgement for `create_task` |
|
||||
| `task_list` | broker → peer | response to task queries |
|
||||
| `vector_store` | peer → broker | store a document in a vector collection |
|
||||
| `vector_search` | peer → broker | search a vector collection |
|
||||
| `vector_delete` | peer → broker | delete a point from a vector collection |
|
||||
| `list_collections` | peer → broker | list all vector collections |
|
||||
| `vector_stored` | broker → peer | acknowledgement for `vector_store` |
|
||||
| `vector_results` | broker → peer | response to `vector_search` |
|
||||
| `collection_list` | broker → peer | response to `list_collections` |
|
||||
| `graph_query` | peer → broker | run a read-only Cypher query |
|
||||
| `graph_execute` | peer → broker | run a write Cypher statement |
|
||||
| `graph_result` | broker → peer | response to graph queries |
|
||||
| `mesh_query` | peer → broker | run a SELECT in the mesh's schema |
|
||||
| `mesh_execute` | peer → broker | run DDL/DML in the mesh's schema |
|
||||
| `mesh_schema` | peer → broker | list tables and columns in the mesh's schema |
|
||||
| `mesh_query_result` | broker → peer | response to `mesh_query` |
|
||||
| `mesh_schema_result` | broker → peer | response to `mesh_schema` |
|
||||
| `mesh_info` | peer → broker | request full mesh overview |
|
||||
| `mesh_info_result` | broker → peer | aggregated mesh overview |
|
||||
| `create_stream` | peer → broker | create a named real-time stream |
|
||||
| `publish` | peer → broker | publish data to a stream |
|
||||
| `subscribe` | peer → broker | subscribe to a stream |
|
||||
| `unsubscribe` | peer → broker | unsubscribe from a stream |
|
||||
| `list_streams` | peer → broker | list all streams in the mesh |
|
||||
| `stream_created` | broker → peer | acknowledgement for `create_stream` |
|
||||
| `stream_data` | broker → peer | real-time data pushed from a stream |
|
||||
| `subscribed` | broker → peer | confirmation of stream subscription |
|
||||
| `stream_list` | broker → peer | response to `list_streams` |
|
||||
| `schedule` | peer → broker | schedule a message for future or recurring delivery|
|
||||
| `list_scheduled` | peer → broker | list pending scheduled messages |
|
||||
| `cancel_scheduled` | peer → broker | cancel a scheduled message by id |
|
||||
| `scheduled_ack` | broker → peer | acknowledgement for `schedule` |
|
||||
| `scheduled_list` | broker → peer | response to `list_scheduled` |
|
||||
| `cancel_scheduled_ack` | broker → peer | confirmation of cancellation |
|
||||
| `get_file` | peer → broker | request a presigned download URL |
|
||||
| `list_files` | peer → broker | list files in the mesh |
|
||||
| `file_status` | peer → broker | get access log for a file |
|
||||
| `delete_file` | peer → broker | soft-delete a file |
|
||||
| `grant_file_access` | peer → broker | grant a peer access to an encrypted file |
|
||||
| `file_url` | broker → peer | presigned download URL |
|
||||
| `file_list` | broker → peer | response to `list_files` |
|
||||
| `file_status_result` | broker → peer | access log for a file |
|
||||
| `grant_file_access_ok` | broker → peer | acknowledgement for `grant_file_access` |
|
||||
| `error` | broker → peer | structured error (handshake, auth, or runtime) |
|
||||
|
||||
Each message carries a monotonic `seq`, a mesh id, and the sender's
|
||||
public key fingerprint. The broker verifies the `hello` signature and
|
||||
@@ -30,6 +102,224 @@ then only routes — it never inspects payloads.
|
||||
|
||||
---
|
||||
|
||||
## Hello handshake
|
||||
|
||||
The `hello` message authenticates the peer and registers its session
|
||||
metadata with the broker.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "hello",
|
||||
"meshId": "acme-payments",
|
||||
"memberId": "m_abc123",
|
||||
"pubkey": "<ed25519 hex>",
|
||||
"sessionPubkey": "<ephemeral ed25519 hex>", // optional
|
||||
"displayName": "Mou", // optional
|
||||
"sessionId": "w1t0p0",
|
||||
"pid": 42781,
|
||||
"cwd": "/home/user/project",
|
||||
"peerType": "ai", // "ai" | "human" | "connector"
|
||||
"channel": "claude-code", // e.g. "claude-code", "telegram", "slack", "web"
|
||||
"model": "opus-4", // AI model identifier
|
||||
"groups": [{ "name": "backend", "role": "lead" }],
|
||||
"timestamp": 1717459200000,
|
||||
"signature": "<ed25519 hex>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------------|-----------------------------------|----------|---------------------------------------------------------|
|
||||
| `meshId` | `string` | yes | Mesh slug |
|
||||
| `memberId` | `string` | yes | Member id from enrollment |
|
||||
| `pubkey` | `string` | yes | ed25519 public key (hex), must match `mesh.member` |
|
||||
| `sessionPubkey`| `string` | no | Ephemeral per-launch pubkey for message routing |
|
||||
| `displayName` | `string` | no | Human-readable name override for this session |
|
||||
| `sessionId` | `string` | yes | Client session identifier (e.g. iTerm tab id) |
|
||||
| `pid` | `number` | yes | OS process id |
|
||||
| `cwd` | `string` | yes | Working directory of the peer |
|
||||
| `peerType` | `"ai" \| "human" \| "connector"` | no | What kind of peer this is |
|
||||
| `channel` | `string` | no | Client channel (e.g. `"claude-code"`, `"slack"`, `"web"`) |
|
||||
| `model` | `string` | no | AI model identifier (e.g. `"opus-4"`, `"sonnet-4"`) |
|
||||
| `groups` | `Array<{name, role?}>` | no | Groups to join on connect |
|
||||
| `timestamp` | `number` | yes | ms epoch; broker rejects if outside ±60 s of its clock |
|
||||
| `signature` | `string` | yes | ed25519 signature over `${meshId}\|${memberId}\|${pubkey}\|${timestamp}` |
|
||||
|
||||
---
|
||||
|
||||
## Peer list
|
||||
|
||||
The `peers_list` response includes session metadata for each connected
|
||||
peer, mirroring the fields sent in `hello`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "peers_list",
|
||||
"peers": [
|
||||
{
|
||||
"pubkey": "<ed25519 hex>",
|
||||
"displayName": "Mou",
|
||||
"status": "working",
|
||||
"summary": "Refactoring the scheduler",
|
||||
"groups": [{ "name": "backend", "role": "lead" }],
|
||||
"sessionId": "w1t0p0",
|
||||
"connectedAt": "2025-06-04T10:30:00Z",
|
||||
"cwd": "/home/user/project",
|
||||
"peerType": "ai",
|
||||
"channel": "claude-code",
|
||||
"model": "opus-4"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|-----------------------------------|----------|----------------------------------------------|
|
||||
| `pubkey` | `string` | yes | Peer's ed25519 public key (hex) |
|
||||
| `displayName` | `string` | yes | Human-readable name |
|
||||
| `status` | `PeerStatus` | yes | `"idle"`, `"working"`, or `"dnd"` |
|
||||
| `summary` | `string \| null` | yes | Session summary set by the peer |
|
||||
| `groups` | `Array<{name, role?}>` | yes | Groups the peer belongs to |
|
||||
| `sessionId` | `string` | yes | Client session identifier |
|
||||
| `connectedAt` | `string` | yes | ISO 8601 timestamp |
|
||||
| `cwd` | `string` | no | Working directory |
|
||||
| `peerType` | `"ai" \| "human" \| "connector"` | no | Peer kind |
|
||||
| `channel` | `string` | no | Client channel |
|
||||
| `model` | `string` | no | AI model identifier |
|
||||
|
||||
---
|
||||
|
||||
## System notifications
|
||||
|
||||
The broker broadcasts topology events as `push` messages with
|
||||
`subtype: "system"`. These are not encrypted — the broker generates
|
||||
them directly.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "push",
|
||||
"messageId": "msg_xyz",
|
||||
"meshId": "acme-payments",
|
||||
"senderPubkey": "<broker pubkey>",
|
||||
"priority": "low",
|
||||
"nonce": "",
|
||||
"ciphertext": "",
|
||||
"createdAt": "2025-06-04T10:30:00Z",
|
||||
"subtype": "system",
|
||||
"event": "peer_joined",
|
||||
"eventData": {
|
||||
"pubkey": "<ed25519 hex>",
|
||||
"displayName": "Mou",
|
||||
"peerType": "ai"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------------|----------------------------|----------|----------------------------------------------------|
|
||||
| `subtype` | `"reminder" \| "system"` | no | `"system"` for topology events, `"reminder"` for scheduled deliveries |
|
||||
| `event` | `string` | no | Machine-readable event name (e.g. `"peer_joined"`, `"peer_left"`) |
|
||||
| `eventData` | `Record<string, unknown>` | no | Structured payload for the event |
|
||||
|
||||
The standard `push` fields (`messageId`, `meshId`, `senderPubkey`,
|
||||
`priority`, `nonce`, `ciphertext`, `createdAt`) are always present.
|
||||
For system notifications, `nonce` and `ciphertext` are empty strings.
|
||||
|
||||
---
|
||||
|
||||
## Scheduled messages
|
||||
|
||||
Peers can schedule one-shot or recurring messages for future delivery.
|
||||
When a scheduled message fires, the recipient receives a standard
|
||||
`push` with `subtype: "reminder"`.
|
||||
|
||||
### `schedule` (peer → broker)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "schedule",
|
||||
"to": "<pubkey or display name>",
|
||||
"message": "Stand-up in 5 minutes",
|
||||
"deliverAt": 1717459200000,
|
||||
"subtype": "reminder",
|
||||
"cron": "0 9 * * 1-5",
|
||||
"recurring": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------------|--------------|----------|------------------------------------------------------------------|
|
||||
| `to` | `string` | yes | Recipient — member pubkey or display name |
|
||||
| `message` | `string` | yes | Plaintext message body |
|
||||
| `deliverAt` | `number` | yes | Unix timestamp (ms). Ignored when `cron` is set. |
|
||||
| `subtype` | `"reminder"` | no | Semantic tag — surfaces differently to the receiver |
|
||||
| `cron` | `string` | no | Standard 5-field cron expression for recurring delivery |
|
||||
| `recurring` | `boolean` | no | Whether this is a recurring schedule. Implied `true` when `cron` is set. |
|
||||
|
||||
### `scheduled_ack` (broker → peer)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "scheduled_ack",
|
||||
"scheduledId": "sched_abc",
|
||||
"deliverAt": 1717459200000,
|
||||
"cron": "0 9 * * 1-5"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|-------------------------------------------|
|
||||
| `scheduledId` | `string` | yes | Assigned id for the scheduled entry |
|
||||
| `deliverAt` | `number` | yes | Resolved delivery time (ms epoch) |
|
||||
| `cron` | `string` | no | Echoed cron expression for recurring entries |
|
||||
|
||||
### `list_scheduled` (peer → broker)
|
||||
|
||||
No payload fields beyond `type`.
|
||||
|
||||
### `scheduled_list` (broker → peer)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "scheduled_list",
|
||||
"messages": [
|
||||
{
|
||||
"id": "sched_abc",
|
||||
"to": "<pubkey>",
|
||||
"message": "Stand-up in 5 minutes",
|
||||
"deliverAt": 1717459200000,
|
||||
"createdAt": 1717372800000,
|
||||
"cron": "0 9 * * 1-5",
|
||||
"firedCount": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|----------|----------|-----------------------------------------------|
|
||||
| `id` | `string` | yes | Scheduled entry id |
|
||||
| `to` | `string` | yes | Recipient |
|
||||
| `message` | `string` | yes | Message body |
|
||||
| `deliverAt` | `number` | yes | Next delivery time (ms epoch) |
|
||||
| `createdAt` | `number` | yes | When the entry was created (ms epoch) |
|
||||
| `cron` | `string` | no | Cron expression, present for recurring entries|
|
||||
| `firedCount` | `number` | no | Times the cron entry has fired so far |
|
||||
|
||||
### `cancel_scheduled` (peer → broker)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|-----------------------------|
|
||||
| `scheduledId` | `string` | yes | Id of the entry to cancel |
|
||||
|
||||
### `cancel_scheduled_ack` (broker → peer)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|-----------|----------|---------------------------------|
|
||||
| `scheduledId` | `string` | yes | Echoed id |
|
||||
| `ok` | `boolean` | yes | Whether cancellation succeeded |
|
||||
|
||||
---
|
||||
|
||||
## Crypto
|
||||
|
||||
- **Signing** — ed25519 (libsodium `crypto_sign`). One keypair per peer
|
||||
|
||||
89
docs/vision-20260407.md
Normal file
89
docs/vision-20260407.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# claudemesh — Vision & Roadmap
|
||||
|
||||
**Date:** 2026-04-07
|
||||
**Author:** Alejandro Gutiérrez + Claude (Opus 4.6)
|
||||
**Last updated:** 2026-04-08 00:19 CEST
|
||||
|
||||
---
|
||||
|
||||
## Shipped (2026-04-07)
|
||||
|
||||
21 features implemented in one session. Full details in [`changelog-20260407.md`](./changelog-20260407.md).
|
||||
|
||||
| # | Feature | Commit |
|
||||
|---|---------|--------|
|
||||
| 1 | Session path (cwd) sharing | `810f372` |
|
||||
| 2 | Peer metadata (type/channel/model) | `810f372` |
|
||||
| 3 | System notifications (join/leave) | `453705a` |
|
||||
| 4 | Cron-based persistent reminders | `e873807` |
|
||||
| 5 | Simulation clock (x1–x100) | `05d9b56` |
|
||||
| 6 | Inbound webhooks | `b55cf26` |
|
||||
| 7 | Slack connector | `5563f90` |
|
||||
| 8 | Telegram connector | `fe92853` |
|
||||
| 9 | SDK (@claudemesh/sdk) | `7e102a2` |
|
||||
| 10 | Mesh skills catalog | `c8cb1e3` |
|
||||
| 11 | Shared project files (+ local/remote detection) | `504111c` + `2c9c8c7` |
|
||||
| 12 | Peer stats reporting | `b3b9972` |
|
||||
| 13 | Signed audit log (SHA-256 hash chain) | `86a2583` |
|
||||
| 14 | Mesh templates (5 presets) | `69e93d4` |
|
||||
| 15 | Default mesh guidance on install | `b0dc538` |
|
||||
| 16 | Mesh MCP proxy (dynamic tools) | `08e289a` |
|
||||
| 17 | Dashboard: peer graph + timeline + resources | `59332dc` + `7d432b3` |
|
||||
| 18 | Peer visibility + public profiles | (types.ts/index.ts) |
|
||||
| 19 | Hostname + local/remote locality | `2c9c8c7` |
|
||||
| 20 | MCP register/unregister broadcasts | `e09671c` |
|
||||
| 21 | File access decision guide | `3641618` |
|
||||
|
||||
---
|
||||
|
||||
## Building now
|
||||
|
||||
### Peer session persistence ("welcome back")
|
||||
Persist peer state (groups, profile, visibility, stats, summary) to DB on disconnect. Restore on reconnect with enriched `hello_ack`. System notification: "Welcome back, Alice! Last seen 2h ago."
|
||||
|
||||
### Persistent MCP registrations
|
||||
MCP servers marked `persistent: true` survive peer disconnect. Marked "offline" instead of deleted. Auto-restored on reconnect. Calls to offline servers return descriptive error.
|
||||
|
||||
---
|
||||
|
||||
## Remaining — not yet built
|
||||
|
||||
### Humans in the mesh
|
||||
Web chat panel on claudemesh.com/dashboard. Humans connect via WS with `peerType: "human"`. Need: typing indicators, read receipts, message history UI.
|
||||
|
||||
**Effort:** 2-3 days.
|
||||
|
||||
### REST API + external WebSocket
|
||||
Authenticated endpoints to send messages, read state, list peers from outside the mesh. API keys per mesh (not session keypairs). External WS: non-Claude clients connect with API key auth.
|
||||
|
||||
**Effort:** 2-3 days. (Webhooks already done.)
|
||||
|
||||
### Bridge / federation
|
||||
**Simple:** A bridge peer joins two meshes and relays tagged messages. Feasible now with the SDK.
|
||||
**Federation:** Broker-to-broker peering protocol. Needs design.
|
||||
|
||||
**Effort:** 1 day (bridge), 1-2 weeks (federation).
|
||||
|
||||
### Sandboxes for code execution
|
||||
Per-mesh compute sandboxes. Peers request: `execute_code(lang: "python", code: "...")`. Prefer third-party integration (E2B, Modal, Fly Machines) over self-hosted.
|
||||
|
||||
**Effort:** 2-3 days (E2B), 1-2 weeks (self-hosted).
|
||||
|
||||
### Spatial topology (proximity-based visibility)
|
||||
Extend visibility with `(x, y)` coordinates and visibility radius. Peers only see others within range. Combined with sim clock, enables spatial simulations (customer walks into store zone, sees sales reps).
|
||||
|
||||
**Effort:** 1 day.
|
||||
|
||||
### Semantic peer search
|
||||
`search_peers(query, filters?)` — multi-field matching across names, groups, roles, summaries, profile capabilities, skills. Ranked results. For meshes with 50+ peers.
|
||||
|
||||
**Effort:** Half day.
|
||||
|
||||
### Mesh telemetry and debugging
|
||||
Structured logging: `mesh_log(level, message, data?)`. Queryable: `mesh_logs(query?, peer?, level?, last?)`. Aggregated reports: `mesh_report(timeframe?)`. AI self-analysis for continuous improvement.
|
||||
|
||||
**Effort:** 1-2 days.
|
||||
|
||||
---
|
||||
|
||||
*Priorities shift as we build and learn. Bridge and humans are the highest-value remaining items.*
|
||||
@@ -30,14 +30,16 @@ The work doubles. The context dies on every restart.
|
||||
|
||||
## What claudemesh does
|
||||
|
||||
claudemesh is a self-hosted broker that connects Claude Code sessions across machines into one live mesh.
|
||||
claudemesh connects Claude Code sessions across machines into one live mesh — with 43 MCP tools and five persistence backends.
|
||||
|
||||
- Every session announces what it is working on.
|
||||
- Any session can message another — by human name, by repo, by machine.
|
||||
- Messages route through a local WebSocket broker you run yourself.
|
||||
- Presence, priority, and status are tracked automatically from each session's activity.
|
||||
- **Messaging:** Send by name, @group, or broadcast. Three priority tiers. E2E encrypted (crypto_box). Scheduled messages and reminders.
|
||||
- **Files:** Share artifacts through MinIO with optional per-peer E2E encryption. Grant access later. Audit trail.
|
||||
- **Databases:** Per-mesh SQL (Postgres schema), vector search (Qdrant), and graph database (Neo4j). Agents create tables, store embeddings, and run Cypher queries.
|
||||
- **State & Memory:** Shared key-value state with instant push. Full-text searchable memory that survives across sessions.
|
||||
- **Streams & Tasks:** Real-time pub/sub data streams. Lightweight task board with claim/complete workflow.
|
||||
- **Presence:** Status detected automatically from Claude Code hooks. Three-source priority model. DND gates.
|
||||
|
||||
No cloud account. No training on your code. Your mesh, your machines, your rules.
|
||||
No training on your code. The broker routes ciphertext — it never reads your messages.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,11 +69,12 @@ Release Claude opens a PR. Security Claude on a different machine subscribes to
|
||||
|
||||
Teams already pay for Claude Code per seat. claudemesh multiplies what those seats do together.
|
||||
|
||||
- **Context survives handoffs.** One agent hands work to the next with full history. No rebuilding.
|
||||
- **Context survives handoffs.** Shared memory, files, and databases carry forward. No rebuilding.
|
||||
- **Decisions stay in the tool.** No copy-paste into Slack, Jira, or a meeting that did not need to happen.
|
||||
- **Work parallelises.** Six agents on six machines can coordinate on the same release without humans playing telephone.
|
||||
- **Your data stays local.** Self-hosted broker. Messages never leave your network.
|
||||
- **Audit trail by default.** Every message, every status, every handoff, logged.
|
||||
- **Work parallelises.** Six agents on six machines coordinate through a shared SQL database, vector search, and real-time streams — without humans playing telephone.
|
||||
- **Your data stays encrypted.** E2E crypto_box on messages and files. The broker routes ciphertext.
|
||||
- **Five persistence layers.** KV state, full-text memory, SQL, vectors, graphs — agents pick the right tool.
|
||||
- **Audit trail by default.** Every message, every status, every file access, logged.
|
||||
|
||||
claudemesh does not replace the engineer. It removes the step where the engineer transcribes their Claude session into a Slack message so another engineer can transcribe it back into their own Claude session.
|
||||
|
||||
|
||||
44
package.json
44
package.json
@@ -47,16 +47,50 @@
|
||||
"duckdb",
|
||||
"better-sqlite3",
|
||||
"sharp"
|
||||
],
|
||||
"overrides": {
|
||||
"csstype": "3.1.3",
|
||||
"@types/react": "19.2.7"
|
||||
}
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.17.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.3"
|
||||
},
|
||||
"overrides": {
|
||||
"csstype": "3.1.3",
|
||||
"@types/react": "19.2.7"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"apps/*",
|
||||
"packages/**",
|
||||
"tooling/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@tanstack/react-query": "5.90.6",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@vitest/coverage-v8": "4.0.14",
|
||||
"@ai-sdk/react": "2.0.94",
|
||||
"ai": "5.0.94",
|
||||
"envin": "1.1.10",
|
||||
"eslint": "9.39.0",
|
||||
"prettier": "3.6.2",
|
||||
"react-hook-form": "7.66.0",
|
||||
"react-native": "0.81.5",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.14",
|
||||
"zod": "4.1.13"
|
||||
},
|
||||
"catalogs": {
|
||||
"node22": {
|
||||
"@types/node": "22.16.0"
|
||||
},
|
||||
"react19": {
|
||||
"@types/react": "19.1.14",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
packages/connector-slack/README.md
Normal file
102
packages/connector-slack/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @claudemesh/connector-slack
|
||||
|
||||
Slack connector for claudemesh -- relay messages between a Slack channel and mesh peers.
|
||||
|
||||
The connector joins the mesh as a peer with `peerType: "connector"` and `channel: "slack"`, bridging messages bidirectionally:
|
||||
|
||||
- **Slack -> Mesh**: Messages from the Slack channel are broadcast to all mesh peers, formatted as `[SlackUser via Slack #channel] message`.
|
||||
- **Mesh -> Slack**: Push messages received from mesh peers are posted to the Slack channel, formatted as `*[MeshPeerName]*: message`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Create a Slack App
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**.
|
||||
2. Name it (e.g. "claudemesh bridge") and select your workspace.
|
||||
|
||||
### 2. Configure Bot Token Scopes
|
||||
|
||||
Under **OAuth & Permissions** > **Bot Token Scopes**, add:
|
||||
|
||||
- `chat:write` -- post messages to channels
|
||||
- `channels:read` -- list public channels
|
||||
- `channels:history` -- read message history in public channels
|
||||
- `users:read` -- resolve user IDs to display names
|
||||
|
||||
### 3. Enable Socket Mode
|
||||
|
||||
Under **Socket Mode**, toggle it **on**. This generates an **App-Level Token** (`xapp-...`). You'll need this for the `SLACK_APP_TOKEN` env var.
|
||||
|
||||
Socket Mode means no public URL is required -- the connector connects outbound to Slack's WebSocket servers.
|
||||
|
||||
### 4. Subscribe to Events
|
||||
|
||||
Under **Event Subscriptions**, enable events and add the following **Bot Events**:
|
||||
|
||||
- `message.channels` -- listen for messages in public channels
|
||||
|
||||
### 5. Install the App
|
||||
|
||||
Under **Install App**, click **Install to Workspace** and authorize. Copy the **Bot User OAuth Token** (`xoxb-...`) for the `SLACK_BOT_TOKEN` env var.
|
||||
|
||||
### 6. Invite the Bot
|
||||
|
||||
Invite the bot to the channel you want to bridge:
|
||||
```
|
||||
/invite @claudemesh-bridge
|
||||
```
|
||||
|
||||
### 7. Get the Channel ID
|
||||
|
||||
Right-click the channel name in Slack > **View channel details** > copy the Channel ID at the bottom (e.g. `C0123456789`).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (`xoxb-...`) |
|
||||
| `SLACK_APP_TOKEN` | Yes | App-Level Token for Socket Mode (`xapp-...`) |
|
||||
| `SLACK_CHANNEL_ID` | Yes | Channel ID to bridge (e.g. `C0123456789`) |
|
||||
| `MESH_BROKER_URL` | Yes | Broker WebSocket URL (e.g. `wss://ic.claudemesh.com/ws`) |
|
||||
| `MESH_ID` | Yes | Mesh UUID |
|
||||
| `MESH_MEMBER_ID` | Yes | Member UUID for this connector's membership |
|
||||
| `MESH_PUBKEY` | Yes | Ed25519 public key (64 hex chars) |
|
||||
| `MESH_SECRET_KEY` | Yes | Ed25519 secret key (128 hex chars) |
|
||||
| `MESH_DISPLAY_NAME` | No | Display name visible to peers (default: `"Slack-connector"`) |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Run
|
||||
SLACK_BOT_TOKEN=xoxb-... \
|
||||
SLACK_APP_TOKEN=xapp-... \
|
||||
SLACK_CHANNEL_ID=C0123456789 \
|
||||
MESH_BROKER_URL=wss://ic.claudemesh.com/ws \
|
||||
MESH_ID=your-mesh-uuid \
|
||||
MESH_MEMBER_ID=your-member-uuid \
|
||||
MESH_PUBKEY=your-pubkey-hex \
|
||||
MESH_SECRET_KEY=your-secret-key-hex \
|
||||
MESH_DISPLAY_NAME="Slack-#general" \
|
||||
npm start
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Slack (Socket Mode) Connector claudemesh Broker
|
||||
| | |
|
||||
|-- message event -------->| |
|
||||
| |-- send (broadcast) ----->|
|
||||
| | |-- push --> peers
|
||||
| | |
|
||||
| |<---- push (from peer) ---|
|
||||
|<-- chat.postMessage -----| |
|
||||
```
|
||||
|
||||
The connector uses Socket Mode for Slack (outbound WebSocket, no public URL needed) and a standard claudemesh WS client for the mesh connection. Both connections auto-reconnect on failure.
|
||||
26
packages/connector-slack/package.json
Normal file
26
packages/connector-slack/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@claudemesh/connector-slack",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack connector for claudemesh — relay messages between Slack channels and mesh peers",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/socket-mode": "^2.0.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.5.13",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
97
packages/connector-slack/src/bridge.ts
Normal file
97
packages/connector-slack/src/bridge.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Bridge — bidirectional message relay between Slack and a claudemesh mesh.
|
||||
*
|
||||
* Slack -> Mesh: messages from the Slack channel are broadcast to mesh peers.
|
||||
* Mesh -> Slack: push messages addressed to this connector (or broadcast)
|
||||
* are posted to the Slack channel.
|
||||
*/
|
||||
|
||||
import type { SlackClient } from "./slack";
|
||||
import type { MeshClient } from "./mesh-client";
|
||||
import type { SlackConnectorConfig } from "./config";
|
||||
|
||||
export class Bridge {
|
||||
private slack: SlackClient;
|
||||
private mesh: MeshClient;
|
||||
private config: SlackConnectorConfig;
|
||||
private unsubSlack: (() => void) | null = null;
|
||||
private unsubMesh: (() => void) | null = null;
|
||||
/** Track message IDs we've relayed to avoid echo loops. */
|
||||
private recentRelayed = new Set<string>();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
slack: SlackClient,
|
||||
mesh: MeshClient,
|
||||
config: SlackConnectorConfig,
|
||||
) {
|
||||
this.slack = slack;
|
||||
this.mesh = mesh;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the bidirectional relay.
|
||||
*/
|
||||
start(): void {
|
||||
// --- Slack -> Mesh ---
|
||||
this.unsubSlack = this.slack.onMessage((msg) => {
|
||||
const channelName = this.config.slackChannelId;
|
||||
const formatted = `[${msg.displayName} via Slack #${channelName}] ${msg.text}`;
|
||||
|
||||
// Broadcast to all mesh peers
|
||||
this.mesh.broadcast(formatted).catch((err) => {
|
||||
console.error("[bridge] Failed to relay Slack->Mesh:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Mesh -> Slack ---
|
||||
this.unsubMesh = this.mesh.onPush((push) => {
|
||||
// Skip messages we ourselves sent (echo prevention)
|
||||
if (this.recentRelayed.has(push.messageId)) {
|
||||
this.recentRelayed.delete(push.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip system events (peer_joined, peer_left) — too noisy for Slack
|
||||
if (push.subtype === "system") return;
|
||||
|
||||
const plaintext = push.plaintext;
|
||||
if (!plaintext) return;
|
||||
|
||||
// Resolve sender name from the push metadata
|
||||
const senderName = push.senderName || push.senderPubkey.slice(0, 8);
|
||||
const formatted = `*[${senderName}]*: ${plaintext}`;
|
||||
|
||||
this.slack.postMessage(formatted).catch((err) => {
|
||||
console.error("[bridge] Failed to relay Mesh->Slack:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Periodically clean the echo-prevention set to prevent memory leaks
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.recentRelayed.clear();
|
||||
}, 60_000);
|
||||
|
||||
console.log("[bridge] Relay started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the relay and clean up subscriptions.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.unsubSlack) {
|
||||
this.unsubSlack();
|
||||
this.unsubSlack = null;
|
||||
}
|
||||
if (this.unsubMesh) {
|
||||
this.unsubMesh();
|
||||
this.unsubMesh = null;
|
||||
}
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
console.log("[bridge] Relay stopped");
|
||||
}
|
||||
}
|
||||
71
packages/connector-slack/src/config.ts
Normal file
71
packages/connector-slack/src/config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Configuration types for the Slack connector.
|
||||
*
|
||||
* All values are loaded from environment variables in index.ts.
|
||||
*/
|
||||
|
||||
export interface SlackConnectorConfig {
|
||||
// Slack
|
||||
/** Bot User OAuth Token (xoxb-...) */
|
||||
slackBotToken: string;
|
||||
/** App-Level Token for Socket Mode (xapp-...) */
|
||||
slackAppToken: string;
|
||||
/** Channel ID to bridge (e.g. C0123456789) */
|
||||
slackChannelId: string;
|
||||
|
||||
// Mesh
|
||||
/** WebSocket URL of the claudemesh broker (wss://...) */
|
||||
brokerUrl: string;
|
||||
/** Mesh UUID */
|
||||
meshId: string;
|
||||
/** Member UUID (this connector's membership) */
|
||||
memberId: string;
|
||||
/** Ed25519 public key, hex-encoded (64 chars) */
|
||||
pubkey: string;
|
||||
/** Ed25519 secret key, hex-encoded (128 chars) */
|
||||
secretKey: string;
|
||||
/** Display name visible to mesh peers (e.g. "Slack-#general") */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from environment variables, throwing on any missing required var.
|
||||
*/
|
||||
export function loadConfigFromEnv(): SlackConnectorConfig {
|
||||
const required: Array<[keyof SlackConnectorConfig, string]> = [
|
||||
["slackBotToken", "SLACK_BOT_TOKEN"],
|
||||
["slackAppToken", "SLACK_APP_TOKEN"],
|
||||
["slackChannelId", "SLACK_CHANNEL_ID"],
|
||||
["brokerUrl", "MESH_BROKER_URL"],
|
||||
["meshId", "MESH_ID"],
|
||||
["memberId", "MESH_MEMBER_ID"],
|
||||
["pubkey", "MESH_PUBKEY"],
|
||||
["secretKey", "MESH_SECRET_KEY"],
|
||||
];
|
||||
|
||||
const missing: string[] = [];
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const [key, envVar] of required) {
|
||||
const val = process.env[envVar];
|
||||
if (!val) {
|
||||
missing.push(envVar);
|
||||
} else {
|
||||
values[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...(values as unknown as Omit<SlackConnectorConfig, "displayName">),
|
||||
displayName:
|
||||
process.env.MESH_DISPLAY_NAME ??
|
||||
process.env.DISPLAY_NAME ??
|
||||
"Slack-connector",
|
||||
};
|
||||
}
|
||||
77
packages/connector-slack/src/index.ts
Normal file
77
packages/connector-slack/src/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @claudemesh/connector-slack — entry point.
|
||||
*
|
||||
* Bridges a Slack channel to a claudemesh mesh, relaying messages
|
||||
* bidirectionally. The connector joins the mesh as a peer with
|
||||
* peerType: "connector" and channel: "slack".
|
||||
*
|
||||
* Usage:
|
||||
* SLACK_BOT_TOKEN=xoxb-... \
|
||||
* SLACK_APP_TOKEN=xapp-... \
|
||||
* SLACK_CHANNEL_ID=C0123456789 \
|
||||
* MESH_BROKER_URL=wss://ic.claudemesh.com/ws \
|
||||
* MESH_ID=... \
|
||||
* MESH_MEMBER_ID=... \
|
||||
* MESH_PUBKEY=... \
|
||||
* MESH_SECRET_KEY=... \
|
||||
* MESH_DISPLAY_NAME="Slack-#general" \
|
||||
* node dist/index.js
|
||||
*/
|
||||
|
||||
import { loadConfigFromEnv } from "./config";
|
||||
import { SlackClient } from "./slack";
|
||||
import { MeshClient } from "./mesh-client";
|
||||
import { Bridge } from "./bridge";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[connector-slack] Loading configuration...");
|
||||
const config = loadConfigFromEnv();
|
||||
|
||||
// --- Connect to mesh ---
|
||||
console.log(
|
||||
`[connector-slack] Connecting to mesh ${config.meshId} at ${config.brokerUrl}...`,
|
||||
);
|
||||
const mesh = new MeshClient(config);
|
||||
await mesh.connect();
|
||||
console.log(
|
||||
`[connector-slack] Mesh connected as "${config.displayName}" (peerType: connector, channel: slack)`,
|
||||
);
|
||||
mesh.setSummary(
|
||||
`Slack connector bridging channel ${config.slackChannelId} to this mesh`,
|
||||
);
|
||||
|
||||
// --- Connect to Slack ---
|
||||
console.log("[connector-slack] Connecting to Slack via Socket Mode...");
|
||||
const slack = new SlackClient(
|
||||
config.slackBotToken,
|
||||
config.slackAppToken,
|
||||
config.slackChannelId,
|
||||
);
|
||||
await slack.connect();
|
||||
console.log(
|
||||
`[connector-slack] Slack connected, listening on channel ${config.slackChannelId}`,
|
||||
);
|
||||
|
||||
// --- Start bridge ---
|
||||
const bridge = new Bridge(slack, mesh, config);
|
||||
bridge.start();
|
||||
console.log("[connector-slack] Bridge active. Relaying messages...");
|
||||
|
||||
// --- Graceful shutdown ---
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
console.log(`\n[connector-slack] Received ${signal}, shutting down...`);
|
||||
bridge.stop();
|
||||
await slack.disconnect();
|
||||
mesh.close();
|
||||
console.log("[connector-slack] Goodbye.");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[connector-slack] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
405
packages/connector-slack/src/mesh-client.ts
Normal file
405
packages/connector-slack/src/mesh-client.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Minimal WebSocket client for the claudemesh broker.
|
||||
*
|
||||
* Handles:
|
||||
* - hello handshake with ed25519 signature (peerType: "connector")
|
||||
* - send / ack message flow
|
||||
* - broadcast (targetSpec: "*")
|
||||
* - inbound push messages
|
||||
* - auto-reconnect with exponential backoff
|
||||
*
|
||||
* Kept intentionally standalone — no dependency on the CLI's BrokerClient
|
||||
* so this package can be installed and run independently.
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
import nacl from "tweetnacl";
|
||||
import naclUtil from "tweetnacl-util";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { SlackConnectorConfig } from "./config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Priority = "now" | "next" | "low";
|
||||
|
||||
export interface InboundPush {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
senderName: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
plaintext: string | null;
|
||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||
subtype?: "reminder" | "system";
|
||||
event?: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PushHandler = (push: InboundPush) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function randomId(): string {
|
||||
return randomBytes(12).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the hello handshake.
|
||||
*
|
||||
* Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}`
|
||||
* Must match the broker's canonicalHello() exactly.
|
||||
*/
|
||||
function signHello(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
pubkey: string,
|
||||
secretKeyHex: string,
|
||||
): { timestamp: number; signature: string } {
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||
const messageBytes = naclUtil.decodeUTF8(canonical);
|
||||
const secretKey = Buffer.from(secretKeyHex, "hex");
|
||||
const sig = nacl.sign.detached(messageBytes, secretKey);
|
||||
return {
|
||||
timestamp,
|
||||
signature: Buffer.from(sig).toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MeshClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
export class MeshClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: SlackConnectorConfig;
|
||||
private closed = false;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private pushHandlers = new Set<PushHandler>();
|
||||
private pushBuffer: InboundPush[] = [];
|
||||
private pendingAcks = new Map<
|
||||
string,
|
||||
{ resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void }
|
||||
>();
|
||||
private outbound: Array<() => void> = [];
|
||||
private _status: "connecting" | "open" | "closed" | "reconnecting" = "closed";
|
||||
|
||||
/** Generate a fresh ed25519 session keypair for this process. */
|
||||
private sessionKeypair = nacl.sign.keyPair();
|
||||
private sessionPubkeyHex = Buffer.from(this.sessionKeypair.publicKey).toString("hex");
|
||||
|
||||
constructor(config: SlackConnectorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get status(): string {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Connection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) throw new Error("client is closed");
|
||||
this._status = "connecting";
|
||||
|
||||
const ws = new WebSocket(this.config.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ws.on("open", () => {
|
||||
const { timestamp, signature } = signHello(
|
||||
this.config.meshId,
|
||||
this.config.memberId,
|
||||
this.config.pubkey,
|
||||
this.config.secretKey,
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.config.meshId,
|
||||
memberId: this.config.memberId,
|
||||
pubkey: this.config.pubkey,
|
||||
sessionPubkey: this.sessionPubkeyHex,
|
||||
displayName: this.config.displayName,
|
||||
sessionId: `connector-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
peerType: "connector" as const,
|
||||
channel: "slack",
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
);
|
||||
|
||||
this.helloTimer = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, HELLO_ACK_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
ws.on("message", (raw: WebSocket.RawData) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this._status = "open";
|
||||
this.reconnectAttempt = 0;
|
||||
this.flushOutbound();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleServerMessage(msg);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this.ws = null;
|
||||
if (this._status !== "open" && this._status !== "reconnecting") {
|
||||
reject(new Error("ws closed before hello_ack"));
|
||||
}
|
||||
if (!this.closed) this.scheduleReconnect();
|
||||
else this._status = "closed";
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
console.error("[mesh-client] ws error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close the connection. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this._status = "closed";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sending
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a message to a targetSpec ("*" for broadcast, pubkey hex for
|
||||
* direct, "@group" for group).
|
||||
*/
|
||||
async send(
|
||||
targetSpec: string,
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
const id = randomId();
|
||||
// Connectors send broadcasts/channels as base64 plaintext.
|
||||
// Direct crypto_box encryption is not implemented here to keep
|
||||
// the connector simple — mesh peers can still identify the sender
|
||||
// by the connector's pubkey.
|
||||
const nonce = randomBytes(24).toString("base64");
|
||||
const ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.pendingAcks.set(id, { resolve });
|
||||
|
||||
const dispatch = (): void => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (this._status === "open") {
|
||||
dispatch();
|
||||
} else {
|
||||
this.outbound.push(dispatch);
|
||||
}
|
||||
|
||||
// Ack timeout
|
||||
setTimeout(() => {
|
||||
if (this.pendingAcks.has(id)) {
|
||||
this.pendingAcks.delete(id);
|
||||
resolve({ ok: false, error: "ack timeout" });
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast a message to all mesh peers. */
|
||||
async broadcast(
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
return this.send("*", message, priority);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Push subscriptions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Subscribe to inbound push messages. Returns an unsubscribe function. */
|
||||
onPush(handler: PushHandler): () => void {
|
||||
this.pushHandlers.add(handler);
|
||||
return () => this.pushHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** Drain buffered pushes (for polling). */
|
||||
drainPushBuffer(): InboundPush[] {
|
||||
const drained = this.pushBuffer.slice();
|
||||
this.pushBuffer.length = 0;
|
||||
return drained;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Set summary / status (fire-and-forget)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
setSummary(summary: string): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
setStatus(status: "idle" | "working" | "dnd"): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_status", status }));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||
if (msg.type === "ack") {
|
||||
const pending = this.pendingAcks.get(String(msg.id ?? ""));
|
||||
if (pending) {
|
||||
pending.resolve({ ok: true, messageId: String(msg.messageId ?? "") });
|
||||
this.pendingAcks.delete(String(msg.id ?? ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "push") {
|
||||
const nonce = String(msg.nonce ?? "");
|
||||
const ciphertext = String(msg.ciphertext ?? "");
|
||||
const senderPubkey = String(msg.senderPubkey ?? "");
|
||||
|
||||
// Decode plaintext — connector receives broadcasts as base64 UTF-8.
|
||||
// Direct (crypto_box) messages from peers will fail to decrypt here
|
||||
// since we don't implement crypto_box_open. That's acceptable —
|
||||
// the connector is meant for broadcast/channel relay, not private DMs.
|
||||
let plaintext: string | null = null;
|
||||
if (ciphertext) {
|
||||
try {
|
||||
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||
// Sanity: check it looks like valid UTF-8 text
|
||||
if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) {
|
||||
plaintext = decoded;
|
||||
}
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
|
||||
const push: InboundPush = {
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
meshId: String(msg.meshId ?? ""),
|
||||
senderPubkey,
|
||||
senderName: String(
|
||||
(msg as Record<string, unknown>).senderName ??
|
||||
(msg as Record<string, unknown>).displayName ??
|
||||
senderPubkey.slice(0, 8),
|
||||
),
|
||||
priority: (msg.priority as Priority) ?? "next",
|
||||
nonce,
|
||||
ciphertext,
|
||||
createdAt: String(msg.createdAt ?? ""),
|
||||
receivedAt: new Date().toISOString(),
|
||||
plaintext,
|
||||
kind: senderPubkey ? "direct" : "unknown",
|
||||
...(msg.subtype
|
||||
? { subtype: msg.subtype as "reminder" | "system" }
|
||||
: {}),
|
||||
...(msg.event ? { event: String(msg.event) } : {}),
|
||||
...(msg.eventData
|
||||
? { eventData: msg.eventData as Record<string, unknown> }
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.pushBuffer.push(push);
|
||||
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
|
||||
|
||||
for (const h of this.pushHandlers) {
|
||||
try {
|
||||
h(push);
|
||||
} catch {
|
||||
/* handler errors are not our problem */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Other message types (peers_list, state_result, etc.) are ignored
|
||||
// by the connector — it only needs send/ack + push.
|
||||
}
|
||||
|
||||
private flushOutbound(): void {
|
||||
const queued = this.outbound.splice(0);
|
||||
for (const fn of queued) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this._status = "reconnecting";
|
||||
const delay =
|
||||
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)];
|
||||
this.reconnectAttempt++;
|
||||
console.log(
|
||||
`[mesh-client] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`,
|
||||
);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect().catch((err) => {
|
||||
console.error("[mesh-client] reconnect failed:", err.message);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
132
packages/connector-slack/src/slack.ts
Normal file
132
packages/connector-slack/src/slack.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Slack client — Socket Mode connection + Web API helpers.
|
||||
*
|
||||
* Uses Socket Mode so users do not need a public URL for Events API.
|
||||
* Listens for messages in a single configured channel and provides
|
||||
* a method to post formatted messages back.
|
||||
*/
|
||||
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { SocketModeClient } from "@slack/socket-mode";
|
||||
|
||||
export interface SlackMessage {
|
||||
/** Slack user ID (e.g. U0123456789) */
|
||||
userId: string;
|
||||
/** Resolved display name (falls back to userId if lookup fails) */
|
||||
displayName: string;
|
||||
/** Message text */
|
||||
text: string;
|
||||
/** Slack channel ID */
|
||||
channelId: string;
|
||||
/** Message timestamp (Slack's unique ID for the message) */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export type SlackMessageHandler = (msg: SlackMessage) => void;
|
||||
|
||||
export class SlackClient {
|
||||
private web: WebClient;
|
||||
private socket: SocketModeClient;
|
||||
private channelId: string;
|
||||
private userCache = new Map<string, string>();
|
||||
private handlers = new Set<SlackMessageHandler>();
|
||||
|
||||
constructor(botToken: string, appToken: string, channelId: string) {
|
||||
this.web = new WebClient(botToken);
|
||||
this.socket = new SocketModeClient({ appToken });
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Slack via Socket Mode and start listening for messages.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
// Verify the bot token works and cache the bot's own user ID
|
||||
// so we can ignore messages from ourselves.
|
||||
const authResult = await this.web.auth.test();
|
||||
const botUserId = authResult.user_id as string;
|
||||
|
||||
this.socket.on("message", async ({ event, ack }) => {
|
||||
// Always acknowledge the event to Slack
|
||||
await ack();
|
||||
|
||||
// Only process messages from the configured channel
|
||||
if (event.channel !== this.channelId) return;
|
||||
|
||||
// Ignore bot's own messages, message_changed edits, and subtypes
|
||||
// like channel_join, channel_leave, etc.
|
||||
if (event.user === botUserId) return;
|
||||
if (event.subtype) return;
|
||||
if (!event.text) return;
|
||||
|
||||
const displayName = await this.resolveUserName(event.user);
|
||||
const msg: SlackMessage = {
|
||||
userId: event.user,
|
||||
displayName,
|
||||
text: event.text,
|
||||
channelId: event.channel,
|
||||
ts: event.ts,
|
||||
};
|
||||
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
} catch {
|
||||
// Handler errors should not break the event loop
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.socket.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to the configured Slack channel.
|
||||
*/
|
||||
async postMessage(text: string): Promise<void> {
|
||||
await this.web.chat.postMessage({
|
||||
channel: this.channelId,
|
||||
text,
|
||||
// Use mrkdwn so mesh peer names can be bolded
|
||||
mrkdwn: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for incoming Slack messages.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onMessage(handler: SlackMessageHandler): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Slack user ID to a display name.
|
||||
* Results are cached for the lifetime of the process.
|
||||
*/
|
||||
async resolveUserName(userId: string): Promise<string> {
|
||||
const cached = this.userCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const result = await this.web.users.info({ user: userId });
|
||||
const name =
|
||||
result.user?.profile?.display_name ||
|
||||
result.user?.real_name ||
|
||||
result.user?.name ||
|
||||
userId;
|
||||
this.userCache.set(userId, name);
|
||||
return name;
|
||||
} catch {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Socket Mode.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
19
packages/connector-slack/tsconfig.json
Normal file
19
packages/connector-slack/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
79
packages/connector-telegram/README.md
Normal file
79
packages/connector-telegram/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @claudemesh/connector-telegram
|
||||
|
||||
Bridges a Telegram chat and a claudemesh mesh, relaying messages bidirectionally. Joins the mesh as `peerType: "connector"`, `channel: "telegram"`.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a Telegram bot
|
||||
|
||||
1. Open Telegram, search for **@BotFather**
|
||||
2. Send `/newbot`, follow the prompts
|
||||
3. Copy the bot token (e.g. `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
|
||||
|
||||
### 2. Get the chat ID
|
||||
|
||||
1. Add your bot to a group chat (or start a DM with it)
|
||||
2. Send a message in the chat
|
||||
3. Fetch updates to find the chat ID:
|
||||
```bash
|
||||
curl https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates | jq '.result[0].message.chat.id'
|
||||
```
|
||||
Group IDs are negative numbers (e.g. `-1001234567890`). DM IDs are positive.
|
||||
|
||||
### 3. Get mesh credentials
|
||||
|
||||
You need a claudemesh membership. Use the CLI to join a mesh and note the credentials, or check your mesh config file (`~/.config/claudemesh/config.json`).
|
||||
|
||||
### 4. Configure environment variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | `123456:ABC-DEF...` |
|
||||
| `TELEGRAM_CHAT_ID` | Target chat ID | `-1001234567890` |
|
||||
| `BROKER_URL` | Broker WebSocket URL | `wss://ic.claudemesh.com/ws` |
|
||||
| `MESH_ID` | Mesh UUID | `abc123-...` |
|
||||
| `MEMBER_ID` | Member UUID | `def456-...` |
|
||||
| `PUBKEY` | Ed25519 public key (hex) | `a1b2c3...` |
|
||||
| `SECRET_KEY` | Ed25519 secret key (hex) | `d4e5f6...` |
|
||||
| `DISPLAY_NAME` | Peer display name (optional) | `Telegram-DevChat` |
|
||||
|
||||
### 5. Run
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Start
|
||||
TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... BROKER_URL=wss://ic.claudemesh.com/ws \
|
||||
MESH_ID=... MEMBER_ID=... PUBKEY=... SECRET_KEY=... DISPLAY_NAME=Telegram-DevChat \
|
||||
npm start
|
||||
```
|
||||
|
||||
Or with npx (once published):
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=... npx @claudemesh/connector-telegram
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
- **Telegram -> Mesh**: Text messages from Telegram are formatted as `[SenderName] message` and broadcast to all mesh peers.
|
||||
- **Mesh -> Telegram**: Messages from mesh peers are formatted as `<b>[PeerName]</b> message` (HTML) and posted to the Telegram chat.
|
||||
- Non-text messages (photos, stickers, etc.) are skipped with a log note.
|
||||
- The connector uses long polling (no webhooks needed, no public URL required).
|
||||
- Auto-reconnects to the mesh broker with exponential backoff.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Telegram Chat <--long poll--> TelegramClient
|
||||
|
|
||||
Bridge (relay)
|
||||
|
|
||||
Mesh Broker <----WebSocket----> MeshClient
|
||||
```
|
||||
|
||||
- `src/config.ts` — Configuration types and env loader
|
||||
- `src/telegram.ts` — Telegram Bot API client (fetch + long polling)
|
||||
- `src/mesh-client.ts` — Minimal claudemesh WS client (tweetnacl for ed25519 signing)
|
||||
- `src/bridge.ts` — Bidirectional message relay
|
||||
- `src/index.ts` — Entry point, wires everything together
|
||||
20
packages/connector-telegram/package.json
Normal file
20
packages/connector-telegram/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@claudemesh/connector-telegram",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram connector for claudemesh — relay messages between Telegram chats and mesh peers",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.5.13",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
96
packages/connector-telegram/src/bridge.ts
Normal file
96
packages/connector-telegram/src/bridge.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Bidirectional bridge between Telegram and a claudemesh mesh.
|
||||
*
|
||||
* Telegram -> Mesh: incoming Telegram messages are formatted as
|
||||
* "[TelegramUser] message" and broadcast to the mesh.
|
||||
*
|
||||
* Mesh -> Telegram: inbound mesh pushes are formatted as
|
||||
* "[MeshPeerName] message" and posted to the Telegram chat.
|
||||
*/
|
||||
|
||||
import type { TelegramClient, TelegramMessage } from "./telegram.js";
|
||||
import type { MeshClient, InboundPush } from "./mesh-client.js";
|
||||
|
||||
export class Bridge {
|
||||
constructor(
|
||||
private telegram: TelegramClient,
|
||||
private mesh: MeshClient,
|
||||
) {}
|
||||
|
||||
/** Wire up both directions. Call once after both clients are connected. */
|
||||
start(): void {
|
||||
// Telegram -> Mesh
|
||||
this.telegram.onMessage((msg: TelegramMessage) => {
|
||||
this.handleTelegramMessage(msg);
|
||||
});
|
||||
|
||||
// Mesh -> Telegram
|
||||
this.mesh.onPush((push: InboundPush) => {
|
||||
this.handleMeshPush(push);
|
||||
});
|
||||
|
||||
console.log("[bridge] relay active");
|
||||
}
|
||||
|
||||
private handleTelegramMessage(msg: TelegramMessage): void {
|
||||
if (!msg.text) {
|
||||
// Skip non-text messages (photos, stickers, etc.)
|
||||
const type = msg.from
|
||||
? "non-text content"
|
||||
: "system message";
|
||||
console.log(`[bridge] skipping ${type} from Telegram`);
|
||||
return;
|
||||
}
|
||||
|
||||
const senderName = formatTelegramSender(msg);
|
||||
const meshMessage = `[${senderName}] ${msg.text}`;
|
||||
|
||||
console.log(`[bridge] tg->mesh: ${meshMessage.slice(0, 80)}...`);
|
||||
|
||||
// Broadcast to all mesh peers
|
||||
this.mesh.send("*", meshMessage).catch((err) => {
|
||||
console.error(`[bridge] failed to relay to mesh:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMeshPush(push: InboundPush): void {
|
||||
// Decode the message content
|
||||
const plaintext = push.plaintext ?? tryDecodeBase64(push.ciphertext);
|
||||
if (!plaintext) return;
|
||||
|
||||
// Skip messages that originated from this connector (prevent echo)
|
||||
if (push.senderPubkey === this.mesh.pubkey) return;
|
||||
|
||||
// Find the sender's display name from the push metadata
|
||||
const senderName = push.senderDisplayName || push.senderPubkey.slice(0, 8);
|
||||
const telegramMessage = `<b>[${escapeHtml(senderName)}]</b> ${escapeHtml(plaintext)}`;
|
||||
|
||||
console.log(`[bridge] mesh->tg: [${senderName}] ${plaintext.slice(0, 60)}...`);
|
||||
|
||||
this.telegram.sendMessage(telegramMessage).catch((err) => {
|
||||
console.error(`[bridge] failed to relay to Telegram:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatTelegramSender(msg: TelegramMessage): string {
|
||||
if (!msg.from) return "Unknown";
|
||||
const parts = [msg.from.first_name];
|
||||
if (msg.from.last_name) parts.push(msg.from.last_name);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function tryDecodeBase64(b64: string): string | null {
|
||||
try {
|
||||
return Buffer.from(b64, "base64").toString("utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
32
packages/connector-telegram/src/config.ts
Normal file
32
packages/connector-telegram/src/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface TelegramConnectorConfig {
|
||||
// Telegram
|
||||
telegramBotToken: string; // from @BotFather
|
||||
telegramChatId: string; // group chat or user chat ID
|
||||
|
||||
// Mesh
|
||||
brokerUrl: string;
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string;
|
||||
displayName: string; // e.g. "Telegram-DevChat"
|
||||
}
|
||||
|
||||
export function loadConfigFromEnv(): TelegramConnectorConfig {
|
||||
const required = (key: string): string => {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing required env var: ${key}`);
|
||||
return val;
|
||||
};
|
||||
|
||||
return {
|
||||
telegramBotToken: required("TELEGRAM_BOT_TOKEN"),
|
||||
telegramChatId: required("TELEGRAM_CHAT_ID"),
|
||||
brokerUrl: required("BROKER_URL"),
|
||||
meshId: required("MESH_ID"),
|
||||
memberId: required("MEMBER_ID"),
|
||||
pubkey: required("PUBKEY"),
|
||||
secretKey: required("SECRET_KEY"),
|
||||
displayName: process.env.DISPLAY_NAME || "Telegram",
|
||||
};
|
||||
}
|
||||
66
packages/connector-telegram/src/index.ts
Normal file
66
packages/connector-telegram/src/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @claudemesh/connector-telegram — Entry point
|
||||
*
|
||||
* Bridges a Telegram chat and a claudemesh mesh, relaying messages
|
||||
* bidirectionally. Joins the mesh as peerType: "connector", channel: "telegram".
|
||||
*
|
||||
* Configuration via environment variables:
|
||||
* TELEGRAM_BOT_TOKEN — Bot token from @BotFather
|
||||
* TELEGRAM_CHAT_ID — Target chat ID (group or user)
|
||||
* BROKER_URL — claudemesh broker WebSocket URL
|
||||
* MESH_ID — Mesh UUID
|
||||
* MEMBER_ID — Member UUID
|
||||
* PUBKEY — Ed25519 public key (hex)
|
||||
* SECRET_KEY — Ed25519 secret key (hex)
|
||||
* DISPLAY_NAME — Peer display name (default: "Telegram")
|
||||
*/
|
||||
|
||||
import { loadConfigFromEnv } from "./config.js";
|
||||
import { TelegramClient } from "./telegram.js";
|
||||
import { MeshClient } from "./mesh-client.js";
|
||||
import { Bridge } from "./bridge.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[connector-telegram] starting...");
|
||||
|
||||
// Load configuration
|
||||
const config = loadConfigFromEnv();
|
||||
console.log(`[connector-telegram] display name: ${config.displayName}`);
|
||||
console.log(`[connector-telegram] chat ID: ${config.telegramChatId}`);
|
||||
console.log(`[connector-telegram] broker: ${config.brokerUrl}`);
|
||||
|
||||
// Initialize clients
|
||||
const telegram = new TelegramClient(config.telegramBotToken, config.telegramChatId);
|
||||
const mesh = new MeshClient(config);
|
||||
|
||||
// Connect to mesh broker
|
||||
console.log("[connector-telegram] connecting to mesh...");
|
||||
await mesh.connect();
|
||||
console.log("[connector-telegram] mesh connected");
|
||||
|
||||
// Start Telegram long polling
|
||||
telegram.start();
|
||||
console.log("[connector-telegram] Telegram polling started");
|
||||
|
||||
// Wire up bidirectional relay
|
||||
const bridge = new Bridge(telegram, mesh);
|
||||
bridge.start();
|
||||
|
||||
console.log("[connector-telegram] bridge active — relaying messages");
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = (): void => {
|
||||
console.log("\n[connector-telegram] shutting down...");
|
||||
telegram.stop();
|
||||
mesh.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[connector-telegram] fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
259
packages/connector-telegram/src/mesh-client.ts
Normal file
259
packages/connector-telegram/src/mesh-client.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Minimal WebSocket client for connecting to a claudemesh broker.
|
||||
* Uses tweetnacl for ed25519 signing (hello handshake).
|
||||
* Stripped down from apps/cli/src/ws/client.ts — hello + send/receive only.
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
import nacl from "tweetnacl";
|
||||
import { decodeUTF8, encodeBase64 } from "tweetnacl-util";
|
||||
import type { TelegramConnectorConfig } from "./config.js";
|
||||
|
||||
export interface InboundPush {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
senderDisplayName?: string;
|
||||
priority: "now" | "next" | "low";
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
plaintext: string | null;
|
||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||
subtype?: "reminder" | "system";
|
||||
event?: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PushHandler = (msg: InboundPush) => void;
|
||||
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
export class MeshClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pushHandlers = new Set<PushHandler>();
|
||||
private closed = false;
|
||||
private reconnectAttempt = 0;
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private connected = false;
|
||||
private outbound: Array<() => void> = [];
|
||||
private peerNames = new Map<string, string>(); // pubkey -> displayName
|
||||
|
||||
readonly pubkey: string;
|
||||
|
||||
constructor(private config: TelegramConnectorConfig) {
|
||||
this.pubkey = config.pubkey;
|
||||
}
|
||||
|
||||
onPush(handler: PushHandler): void {
|
||||
this.pushHandlers.add(handler);
|
||||
}
|
||||
|
||||
/** Open WS, send hello, resolve when hello_ack received. */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) throw new Error("client is closed");
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(this.config.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on("open", () => {
|
||||
console.log("[mesh] ws open, sending hello");
|
||||
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${this.config.meshId}|${this.config.memberId}|${this.config.pubkey}|${timestamp}`;
|
||||
const secretKey = hexToUint8(this.config.secretKey);
|
||||
const sigBytes = nacl.sign.detached(decodeUTF8(canonical), secretKey);
|
||||
const signature = uint8ToHex(sigBytes);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.config.meshId,
|
||||
memberId: this.config.memberId,
|
||||
pubkey: this.config.pubkey,
|
||||
displayName: this.config.displayName,
|
||||
sessionId: `connector-tg-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
peerType: "connector",
|
||||
channel: "telegram",
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
);
|
||||
|
||||
this.helloTimer = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, HELLO_ACK_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
ws.on("message", (raw: WebSocket.RawData) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
this.flushOutbound();
|
||||
console.log("[mesh] connected to broker");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleServerMessage(msg);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this.ws = null;
|
||||
const wasConnected = this.connected;
|
||||
this.connected = false;
|
||||
if (!wasConnected) {
|
||||
reject(new Error("ws closed before hello_ack"));
|
||||
}
|
||||
if (!this.closed) this.scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
console.error(`[mesh] ws error: ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a message to the mesh. targetSpec: "*" for broadcast, pubkey for direct. */
|
||||
async send(
|
||||
targetSpec: string,
|
||||
message: string,
|
||||
priority: "now" | "next" | "low" = "next",
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const id = randomId();
|
||||
// Connectors send plaintext broadcasts (base64 encoded) —
|
||||
// direct crypto_box encryption is omitted for simplicity.
|
||||
const nonce = encodeBase64(nacl.randomBytes(24));
|
||||
const ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const dispatch = (): void => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (this.connected) {
|
||||
dispatch();
|
||||
} else {
|
||||
this.outbound.push(dispatch);
|
||||
}
|
||||
|
||||
// Ack timeout
|
||||
setTimeout(() => {
|
||||
resolve({ ok: false, error: "ack timeout" });
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||
if (msg.type === "push") {
|
||||
const push = msg as unknown as InboundPush & { senderDisplayName?: string };
|
||||
|
||||
// Decode plaintext for broadcasts/channel messages
|
||||
if (!push.plaintext && push.ciphertext) {
|
||||
try {
|
||||
push.plaintext = Buffer.from(push.ciphertext, "base64").toString("utf-8");
|
||||
} catch {
|
||||
// leave null
|
||||
}
|
||||
}
|
||||
|
||||
// Cache peer display name if provided
|
||||
if (push.senderDisplayName && push.senderPubkey) {
|
||||
this.peerNames.set(push.senderPubkey, push.senderDisplayName);
|
||||
}
|
||||
|
||||
for (const handler of this.pushHandlers) {
|
||||
try {
|
||||
handler(push);
|
||||
} catch (err) {
|
||||
console.error("[mesh] push handler error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === "peers") {
|
||||
// Cache peer names from peer list responses
|
||||
const peers = (msg as Record<string, unknown>).peers as Array<{ pubkey: string; displayName: string }> | undefined;
|
||||
if (peers) {
|
||||
for (const p of peers) {
|
||||
this.peerNames.set(p.pubkey, p.displayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private flushOutbound(): void {
|
||||
const fns = this.outbound.splice(0);
|
||||
for (const fn of fns) fn();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const delay = BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
|
||||
this.reconnectAttempt++;
|
||||
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect().catch((err) => {
|
||||
console.error(`[mesh] reconnect failed:`, err);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hex helpers (avoid libsodium dependency) ---
|
||||
|
||||
function hexToUint8(hex: string): Uint8Array {
|
||||
const len = hex.length / 2;
|
||||
const arr = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function uint8ToHex(arr: Uint8Array): string {
|
||||
return Array.from(arr)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
148
packages/connector-telegram/src/telegram.ts
Normal file
148
packages/connector-telegram/src/telegram.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Minimal Telegram Bot API client using fetch + long polling.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
const POLL_TIMEOUT_SECS = 30;
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
from?: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
};
|
||||
chat: { id: number; type: string; title?: string };
|
||||
date: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface Update {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
}
|
||||
|
||||
interface GetUpdatesResponse {
|
||||
ok: boolean;
|
||||
result: Update[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SendMessageResponse {
|
||||
ok: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MessageHandler = (msg: TelegramMessage) => void;
|
||||
|
||||
export class TelegramClient {
|
||||
private baseUrl: string;
|
||||
private offset = 0;
|
||||
private running = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private handlers = new Set<MessageHandler>();
|
||||
|
||||
constructor(
|
||||
private botToken: string,
|
||||
private chatId: string,
|
||||
) {
|
||||
this.baseUrl = `https://api.telegram.org/bot${botToken}`;
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler): void {
|
||||
this.handlers.add(handler);
|
||||
}
|
||||
|
||||
/** Send a text message to the configured chat. */
|
||||
async sendMessage(text: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as SendMessageResponse;
|
||||
if (!data.ok) {
|
||||
console.error(`[telegram] sendMessage failed: ${data.description}`);
|
||||
}
|
||||
return data.ok;
|
||||
} catch (err) {
|
||||
console.error(`[telegram] sendMessage error:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start long-polling loop. Non-blocking — runs in background. */
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.pollLoop();
|
||||
}
|
||||
|
||||
/** Stop the polling loop gracefully. */
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
private async pollLoop(): Promise<void> {
|
||||
while (this.running) {
|
||||
try {
|
||||
this.abortController = new AbortController();
|
||||
const url = new URL(`${this.baseUrl}/getUpdates`);
|
||||
url.searchParams.set("offset", String(this.offset));
|
||||
url.searchParams.set("timeout", String(POLL_TIMEOUT_SECS));
|
||||
url.searchParams.set("allowed_updates", JSON.stringify(["message"]));
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal: this.abortController.signal,
|
||||
// Allow enough time for the long-poll plus network overhead
|
||||
});
|
||||
|
||||
const data = (await res.json()) as GetUpdatesResponse;
|
||||
|
||||
if (!data.ok) {
|
||||
console.error(`[telegram] getUpdates failed: ${data.description}`);
|
||||
await sleep(5_000);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const update of data.result) {
|
||||
this.offset = update.update_id + 1;
|
||||
if (update.message) {
|
||||
this.dispatchMessage(update.message);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Expected on stop()
|
||||
break;
|
||||
}
|
||||
console.error(`[telegram] poll error:`, err);
|
||||
await sleep(5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchMessage(msg: TelegramMessage): void {
|
||||
// Only relay messages from the configured chat
|
||||
if (String(msg.chat.id) !== this.chatId) return;
|
||||
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
} catch (err) {
|
||||
console.error(`[telegram] handler error:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
17
packages/connector-telegram/tsconfig.json
Normal file
17
packages/connector-telegram/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
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");
|
||||
16
packages/db/migrations/0014_peer-state-persistence.sql
Normal file
16
packages/db/migrations/0014_peer-state-persistence.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Peer session persistence: save state on disconnect, restore on reconnect.
|
||||
CREATE TABLE IF NOT EXISTS mesh.peer_state (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
member_id TEXT NOT NULL REFERENCES mesh.member(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
groups JSONB DEFAULT '[]',
|
||||
profile JSONB DEFAULT '{}',
|
||||
visible BOOLEAN NOT NULL DEFAULT true,
|
||||
last_summary TEXT,
|
||||
last_display_name TEXT,
|
||||
cumulative_stats JSONB DEFAULT '{"messagesIn":0,"messagesOut":0,"toolCalls":0,"errors":0}',
|
||||
last_seen_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT peer_state_mesh_member_idx UNIQUE (mesh_id, member_id)
|
||||
);
|
||||
@@ -166,19 +166,28 @@ export const invite = meshSchema.table("invite", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Metadata-only audit log. NEVER stores message content — every
|
||||
* Signed, hash-chained audit log. NEVER stores message content — every
|
||||
* payload between peers is E2E encrypted client-side (libsodium), so
|
||||
* the broker/DB only ever see ciphertext + routing events.
|
||||
*
|
||||
* Each entry includes a SHA-256 hash of the previous entry's hash,
|
||||
* forming a tamper-evident chain per mesh. If any row is modified,
|
||||
* all subsequent hashes break — detectable via verifyChain().
|
||||
*
|
||||
* This table is append-only: no UPDATE or DELETE operations.
|
||||
*/
|
||||
export const auditLog = meshSchema.table("audit_log", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
/** Serial-like integer PK for ordering. */
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
eventType: text().notNull(),
|
||||
actorPeerId: text(),
|
||||
targetPeerId: text(),
|
||||
metadata: jsonb().notNull().default({}),
|
||||
actorMemberId: text(),
|
||||
actorDisplayName: text(),
|
||||
payload: jsonb().notNull().default({}),
|
||||
prevHash: text().notNull(),
|
||||
hash: text().notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -305,6 +314,8 @@ export const meshFile = meshSchema.table("file", {
|
||||
minioKey: text().notNull(),
|
||||
tags: text().array().default([]),
|
||||
persistent: boolean().notNull().default(true),
|
||||
encrypted: boolean().notNull().default(false),
|
||||
ownerPubkey: text(),
|
||||
uploadedByName: text(),
|
||||
uploadedByMember: text().references(() => meshMember.id),
|
||||
targetSpec: text(), // null = entire mesh
|
||||
@@ -328,24 +339,60 @@ export const meshFileAccess = meshSchema.table("file_access", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-peer context snapshot. Each peer (presence) has at most one context
|
||||
* Per-peer encrypted symmetric keys for E2E encrypted files.
|
||||
* The file body is encrypted with a random key (Kf); Kf is sealed
|
||||
* (crypto_box_seal) to each authorized peer's X25519 pubkey and stored here.
|
||||
*/
|
||||
export const meshFileKey = meshSchema.table("file_key", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
fileId: text()
|
||||
.references(() => meshFile.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
peerPubkey: text().notNull(),
|
||||
sealedKey: text().notNull(),
|
||||
grantedAt: timestamp().defaultNow().notNull(),
|
||||
grantedByPubkey: text(),
|
||||
});
|
||||
|
||||
export const meshFileKeyRelations = relations(meshFileKey, ({ one }) => ({
|
||||
file: one(meshFile, {
|
||||
fields: [meshFileKey.fileId],
|
||||
references: [meshFile.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Per-peer context snapshot. Each peer (member) has at most one context
|
||||
* entry per mesh, upserted on each share_context call. Allows peers to
|
||||
* discover what others are working on, which files they've read, and
|
||||
* key findings — without sending a direct message.
|
||||
*
|
||||
* `memberId` is the stable upsert key (survives reconnects). `presenceId`
|
||||
* is kept for backwards-compat but is nullable — new rows should always
|
||||
* populate `memberId`. The unique index on (meshId, memberId) prevents
|
||||
* stale rows from accumulating when a session reconnects with a new
|
||||
* ephemeral presenceId.
|
||||
*/
|
||||
export const meshContext = meshSchema.table("context", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
|
||||
peerName: text(),
|
||||
summary: text().notNull(),
|
||||
filesRead: text().array().default([]),
|
||||
keyFindings: text().array().default([]),
|
||||
tags: text().array().default([]),
|
||||
updatedAt: timestamp().defaultNow().notNull(),
|
||||
});
|
||||
export const meshContext = meshSchema.table(
|
||||
"context",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
memberId: text().references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
|
||||
peerName: text(),
|
||||
summary: text().notNull(),
|
||||
filesRead: text().array().default([]),
|
||||
keyFindings: text().array().default([]),
|
||||
tags: text().array().default([]),
|
||||
updatedAt: timestamp().defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("context_mesh_member_idx").on(table.meshId, table.memberId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Mesh-scoped task board. Peers can create tasks, claim them, and mark
|
||||
@@ -389,6 +436,112 @@ export const meshStream = meshSchema.table(
|
||||
(table) => [uniqueIndex("stream_mesh_name_idx").on(table.meshId, table.name)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reusable skills (instructions/capabilities) shared across a mesh.
|
||||
* Peers publish skills so other peers can discover and load them.
|
||||
* Skills are scoped to a mesh and unique by (meshId, name).
|
||||
*/
|
||||
export const meshSkill = meshSchema.table(
|
||||
"skill",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
name: text().notNull(),
|
||||
description: text().notNull(),
|
||||
instructions: text().notNull(),
|
||||
tags: text().array().default([]),
|
||||
authorMemberId: text().references(() => meshMember.id),
|
||||
authorName: text(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
updatedAt: timestamp().defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("skill_mesh_name_idx").on(table.meshId, table.name)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Persistent scheduled messages. Survives broker restarts — on boot the
|
||||
* broker loads all non-cancelled, non-expired rows and re-arms timers.
|
||||
* Supports both one-shot (deliverAt) and recurring (cron expression).
|
||||
*/
|
||||
/**
|
||||
* Inbound webhooks: external services POST to a broker endpoint and the
|
||||
* payload is pushed to all connected mesh peers as a "webhook" push.
|
||||
*/
|
||||
export const meshWebhook = meshSchema.table(
|
||||
"webhook",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
name: text().notNull(),
|
||||
secret: text().notNull(),
|
||||
active: boolean().notNull().default(true),
|
||||
createdBy: text()
|
||||
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("webhook_mesh_name_idx").on(table.meshId, table.name)],
|
||||
);
|
||||
|
||||
export const meshWebhookRelations = relations(meshWebhook, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [meshWebhook.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
creator: one(meshMember, {
|
||||
fields: [meshWebhook.createdBy],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectMeshWebhookSchema = createSelectSchema(meshWebhook);
|
||||
export const insertMeshWebhookSchema = createInsertSchema(meshWebhook);
|
||||
export type SelectMeshWebhook = typeof meshWebhook.$inferSelect;
|
||||
export type InsertMeshWebhook = typeof meshWebhook.$inferInsert;
|
||||
|
||||
export const scheduledMessage = meshSchema.table("scheduled_message", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
/** Nullable — the presence that created it may be gone after a restart. */
|
||||
presenceId: text(),
|
||||
memberId: text()
|
||||
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
to: text().notNull(),
|
||||
message: text().notNull(),
|
||||
/** Unix timestamp (ms) for one-shot delivery. Null for cron-only entries. */
|
||||
deliverAt: timestamp(),
|
||||
/** 5-field cron expression for recurring delivery. Null for one-shot. */
|
||||
cron: text(),
|
||||
subtype: text(),
|
||||
firedCount: integer().notNull().default(0),
|
||||
cancelled: boolean().notNull().default(false),
|
||||
firedAt: timestamp(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const scheduledMessageRelations = relations(scheduledMessage, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [scheduledMessage.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
member: one(meshMember, {
|
||||
fields: [scheduledMessage.memberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectScheduledMessageSchema = createSelectSchema(scheduledMessage);
|
||||
export const insertScheduledMessageSchema = createInsertSchema(scheduledMessage);
|
||||
export type SelectScheduledMessage = typeof scheduledMessage.$inferSelect;
|
||||
export type InsertScheduledMessage = typeof scheduledMessage.$inferInsert;
|
||||
|
||||
export const meshRelations = relations(mesh, ({ one, many }) => ({
|
||||
owner: one(user, {
|
||||
fields: [mesh.ownerUserId],
|
||||
@@ -531,6 +684,10 @@ export type SelectMeshFile = typeof meshFile.$inferSelect;
|
||||
export type InsertMeshFile = typeof meshFile.$inferInsert;
|
||||
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
|
||||
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;
|
||||
export const selectMeshFileKeySchema = createSelectSchema(meshFileKey);
|
||||
export const insertMeshFileKeySchema = createInsertSchema(meshFileKey);
|
||||
export type SelectMeshFileKey = typeof meshFileKey.$inferSelect;
|
||||
export type InsertMeshFileKey = typeof meshFileKey.$inferInsert;
|
||||
export const selectMeshContextSchema = createSelectSchema(meshContext);
|
||||
export const insertMeshContextSchema = createInsertSchema(meshContext);
|
||||
export const selectMeshTaskSchema = createSelectSchema(meshTask);
|
||||
@@ -573,3 +730,66 @@ export const selectMeshStreamSchema = createSelectSchema(meshStream);
|
||||
export const insertMeshStreamSchema = createInsertSchema(meshStream);
|
||||
export type SelectMeshStream = typeof meshStream.$inferSelect;
|
||||
export type InsertMeshStream = typeof meshStream.$inferInsert;
|
||||
|
||||
/**
|
||||
* Persisted peer session state. Survives disconnects — when a peer
|
||||
* reconnects (same meshId + memberId), the broker restores groups,
|
||||
* profile, visibility, summary, and cumulative stats automatically.
|
||||
* Keyed by (meshId, memberId) — one row per member per mesh.
|
||||
*/
|
||||
export const peerState = meshSchema.table(
|
||||
"peer_state",
|
||||
{
|
||||
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" })
|
||||
.notNull(),
|
||||
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
|
||||
profile: jsonb().$type<{ avatar?: string; title?: string; bio?: string; capabilities?: string[] }>().default({}),
|
||||
visible: boolean().notNull().default(true),
|
||||
lastSummary: text(),
|
||||
lastDisplayName: text(),
|
||||
cumulativeStats: jsonb().$type<{ messagesIn: number; messagesOut: number; toolCalls: number; errors: number }>().default({ messagesIn: 0, messagesOut: 0, toolCalls: 0, errors: 0 }),
|
||||
lastSeenAt: timestamp(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
updatedAt: timestamp().defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("peer_state_mesh_member_idx").on(table.meshId, table.memberId),
|
||||
],
|
||||
);
|
||||
|
||||
export const peerStateRelations = relations(peerState, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [peerState.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
member: one(meshMember, {
|
||||
fields: [peerState.memberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectPeerStateSchema = createSelectSchema(peerState);
|
||||
export const insertPeerStateSchema = createInsertSchema(peerState);
|
||||
export type SelectPeerState = typeof peerState.$inferSelect;
|
||||
export type InsertPeerState = typeof peerState.$inferInsert;
|
||||
|
||||
export const meshSkillRelations = relations(meshSkill, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [meshSkill.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
author: one(meshMember, {
|
||||
fields: [meshSkill.authorMemberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectMeshSkillSchema = createSelectSchema(meshSkill);
|
||||
export const insertMeshSkillSchema = createInsertSchema(meshSkill);
|
||||
export type SelectMeshSkill = typeof meshSkill.$inferSelect;
|
||||
export type InsertMeshSkill = typeof meshSkill.$inferInsert;
|
||||
|
||||
100
packages/sdk/README.md
Normal file
100
packages/sdk/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# @claudemesh/sdk
|
||||
|
||||
Lightweight TypeScript SDK for connecting any process to a claudemesh mesh. Handles WebSocket connections, ed25519 authentication, crypto_box encryption, and auto-reconnect.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @claudemesh/sdk
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { MeshClient, generateKeyPair } from "@claudemesh/sdk";
|
||||
|
||||
const keys = generateKeyPair();
|
||||
const client = new MeshClient({
|
||||
brokerUrl: "wss://ic.claudemesh.com/ws",
|
||||
meshId: "your-mesh-id",
|
||||
memberId: "your-member-id",
|
||||
pubkey: keys.publicKey,
|
||||
secretKey: keys.secretKey,
|
||||
displayName: "My Bot",
|
||||
peerType: "connector",
|
||||
channel: "custom",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Listen for messages
|
||||
client.on("message", (msg) => {
|
||||
console.log(`From ${msg.senderPubkey}: ${msg.plaintext}`);
|
||||
});
|
||||
|
||||
// Listen for peer events
|
||||
client.on("peer_joined", (peer) => {
|
||||
console.log(`${peer.displayName} joined`);
|
||||
});
|
||||
|
||||
client.on("peer_left", (peer) => {
|
||||
console.log(`${peer.displayName} left`);
|
||||
});
|
||||
|
||||
// Send a message (by display name or pubkey)
|
||||
await client.send("Alice", "Hello from SDK!");
|
||||
|
||||
// Broadcast to all peers
|
||||
await client.broadcast("Hello everyone!");
|
||||
|
||||
// List connected peers
|
||||
const peers = await client.listPeers();
|
||||
|
||||
// Shared state
|
||||
await client.setState("build_status", "passing");
|
||||
const value = await client.getState("build_status");
|
||||
|
||||
// Clean up
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `generateKeyPair()`
|
||||
|
||||
Returns `Promise<{ publicKey: string; secretKey: string }>` -- an ed25519 keypair with hex-encoded keys.
|
||||
|
||||
### `new MeshClient(opts)`
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
|--------|------|----------|-------------|
|
||||
| `brokerUrl` | `string` | yes | WebSocket URL of the broker |
|
||||
| `meshId` | `string` | yes | Mesh to join |
|
||||
| `memberId` | `string` | yes | Your member ID within the mesh |
|
||||
| `pubkey` | `string` | yes | Ed25519 public key (hex) |
|
||||
| `secretKey` | `string` | yes | Ed25519 secret key (hex) |
|
||||
| `displayName` | `string` | no | Name visible to other peers |
|
||||
| `peerType` | `"ai" \| "human" \| "connector"` | no | Defaults to `"connector"` |
|
||||
| `channel` | `string` | no | Channel identifier |
|
||||
| `debug` | `boolean` | no | Log debug info to stderr |
|
||||
|
||||
### Methods
|
||||
|
||||
- `connect(): Promise<void>` -- Open connection and authenticate
|
||||
- `disconnect(): void` -- Close connection
|
||||
- `send(to, message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to peer name, pubkey, `*`, or `@group`
|
||||
- `broadcast(message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to all peers
|
||||
- `listPeers(): Promise<PeerInfo[]>` -- List connected peers
|
||||
- `getState(key): Promise<string | null>` -- Read shared state
|
||||
- `setState(key, value): Promise<void>` -- Write shared state
|
||||
- `setSummary(summary): Promise<void>` -- Set session summary
|
||||
- `setStatus(status): Promise<void>` -- Set status (`idle`, `working`, `dnd`)
|
||||
|
||||
### Events
|
||||
|
||||
- `"message"` -- Inbound message received
|
||||
- `"connected"` -- WebSocket authenticated
|
||||
- `"disconnected"` -- WebSocket closed
|
||||
- `"peer_joined"` -- A peer connected to the mesh
|
||||
- `"peer_left"` -- A peer disconnected
|
||||
- `"state_change"` -- Shared state was updated by a peer
|
||||
19
packages/sdk/package.json
Normal file
19
packages/sdk/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@claudemesh/sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "SDK for connecting any process to a claudemesh mesh",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.5.13",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
564
packages/sdk/src/client.ts
Normal file
564
packages/sdk/src/client.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* MeshClient -- lightweight WebSocket client for connecting any process
|
||||
* to a claudemesh mesh. Handles:
|
||||
* - hello handshake + ack
|
||||
* - send / ack / push message flow
|
||||
* - auto-reconnect with exponential backoff
|
||||
* - crypto_box encryption for direct messages
|
||||
* - EventEmitter interface for messages, connection, and peer events
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import WebSocket from "ws";
|
||||
import {
|
||||
signHello,
|
||||
generateKeyPair,
|
||||
encryptDirect,
|
||||
decryptDirect,
|
||||
isDirectTarget,
|
||||
} from "./crypto.js";
|
||||
import type {
|
||||
MeshClientOptions,
|
||||
PeerInfo,
|
||||
InboundMessage,
|
||||
Priority,
|
||||
ConnStatus,
|
||||
} from "./types.js";
|
||||
|
||||
interface PendingSend {
|
||||
id: string;
|
||||
targetSpec: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void;
|
||||
}
|
||||
|
||||
const MAX_QUEUED = 100;
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
export interface MeshClientEvents {
|
||||
message: [msg: InboundMessage];
|
||||
connected: [];
|
||||
disconnected: [];
|
||||
peer_joined: [peer: PeerInfo];
|
||||
peer_left: [peer: PeerInfo];
|
||||
state_change: [change: { key: string; value: unknown; updatedBy: string }];
|
||||
}
|
||||
|
||||
export class MeshClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private _status: ConnStatus = "closed";
|
||||
private pendingSends = new Map<string, PendingSend>();
|
||||
private outbound: Array<() => void> = [];
|
||||
private closed = false;
|
||||
private reconnectAttempt = 0;
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Session keypair (generated on first connect, reused across reconnects)
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
|
||||
// Request-response resolvers
|
||||
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;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(private opts: MeshClientOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Current connection status. */
|
||||
get status(): ConnStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** Session public key hex (null before first connect). */
|
||||
get pubkey(): string | null {
|
||||
return this.sessionPubkey;
|
||||
}
|
||||
|
||||
/** Open the WebSocket, send hello, resolve when hello_ack received. */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) throw new Error("client is closed");
|
||||
this._status = "connecting";
|
||||
const ws = new WebSocket(this.opts.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onOpen = async (): Promise<void> => {
|
||||
this.debug("ws open -> generating session keypair + signing hello");
|
||||
try {
|
||||
if (!this.sessionPubkey) {
|
||||
const sessionKP = await generateKeyPair();
|
||||
this.sessionPubkey = sessionKP.publicKey;
|
||||
this.sessionSecretKey = sessionKP.secretKey;
|
||||
}
|
||||
|
||||
const { timestamp, signature } = await signHello(
|
||||
this.opts.meshId,
|
||||
this.opts.memberId,
|
||||
this.opts.pubkey,
|
||||
this.opts.secretKey,
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.opts.meshId,
|
||||
memberId: this.opts.memberId,
|
||||
pubkey: this.opts.pubkey,
|
||||
sessionPubkey: this.sessionPubkey,
|
||||
displayName: this.opts.displayName,
|
||||
sessionId: `sdk-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
peerType: this.opts.peerType ?? "connector",
|
||||
channel: this.opts.channel ?? "sdk",
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
reject(
|
||||
new Error(
|
||||
`hello sign failed: ${e instanceof Error ? e.message : e}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.helloTimer = setTimeout(() => {
|
||||
this.debug("hello_ack timeout");
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, HELLO_ACK_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const onMessage = (raw: WebSocket.RawData): void => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "hello_ack") {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this._status = "open";
|
||||
this.reconnectAttempt = 0;
|
||||
this.flushOutbound();
|
||||
this.emit("connected");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.handleServerMessage(msg);
|
||||
};
|
||||
|
||||
const onClose = (): void => {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
const wasOpen = this._status === "open" || this._status === "reconnecting";
|
||||
this.ws = null;
|
||||
if (!wasOpen && this._status === "connecting") {
|
||||
reject(new Error("ws closed before hello_ack"));
|
||||
}
|
||||
if (!this.closed) {
|
||||
this.emit("disconnected");
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this._status = "closed";
|
||||
this.emit("disconnected");
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error): void => {
|
||||
this.debug(`ws error: ${err.message}`);
|
||||
};
|
||||
|
||||
ws.on("open", onOpen);
|
||||
ws.on("message", onMessage);
|
||||
ws.on("close", onClose);
|
||||
ws.on("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close the connection. */
|
||||
disconnect(): void {
|
||||
this.closed = true;
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this._status = "closed";
|
||||
}
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
/**
|
||||
* Send a message to a peer. `to` can be:
|
||||
* - A hex pubkey (64 chars) for encrypted direct message
|
||||
* - A display name (resolved via listPeers)
|
||||
* - "*" for broadcast
|
||||
* - "@groupname" for group message
|
||||
*/
|
||||
async send(
|
||||
to: string,
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
// Resolve display name to pubkey for direct encryption
|
||||
let targetSpec = to;
|
||||
if (!isDirectTarget(to) && to !== "*" && !to.startsWith("@") && !to.startsWith("#")) {
|
||||
const peers = await this.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (match) {
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
// If no match found, send as-is and let the broker resolve
|
||||
}
|
||||
|
||||
const id = randomBytes(8).toString("hex");
|
||||
let nonce: string;
|
||||
let ciphertext: string;
|
||||
|
||||
if (isDirectTarget(targetSpec)) {
|
||||
const env = await encryptDirect(
|
||||
message,
|
||||
targetSpec,
|
||||
this.sessionSecretKey ?? this.opts.secretKey,
|
||||
);
|
||||
nonce = env.nonce;
|
||||
ciphertext = env.ciphertext;
|
||||
} else {
|
||||
nonce = randomBytes(24).toString("base64");
|
||||
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (this.pendingSends.size >= MAX_QUEUED) {
|
||||
resolve({ ok: false, error: "outbound queue full" });
|
||||
return;
|
||||
}
|
||||
this.pendingSends.set(id, {
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
resolve,
|
||||
});
|
||||
const dispatch = (): void => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (this._status === "open") dispatch();
|
||||
else {
|
||||
if (this.outbound.length >= MAX_QUEUED) {
|
||||
this.pendingSends.delete(id);
|
||||
resolve({ ok: false, error: "outbound queue full" });
|
||||
return;
|
||||
}
|
||||
this.outbound.push(dispatch);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (this.pendingSends.has(id)) {
|
||||
this.pendingSends.delete(id);
|
||||
resolve({ ok: false, error: "ack timeout" });
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast a message to all peers in the mesh. */
|
||||
async broadcast(
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
return this.send("*", message, priority);
|
||||
}
|
||||
|
||||
// --- Peers ---
|
||||
|
||||
/** Request the list of connected peers from the broker. */
|
||||
async listPeers(): Promise<PeerInfo[]> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.listPeersResolvers.set(reqId, {
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
if (this.listPeersResolvers.delete(reqId)) resolve([]);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(JSON.stringify({ type: "list_peers", _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
/** Read a shared state value. */
|
||||
async getState(
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.stateResolvers.set(reqId, {
|
||||
resolve: (result) => resolve(result ? String(result.value) : null),
|
||||
timer: setTimeout(() => {
|
||||
if (this.stateResolvers.delete(reqId)) resolve(null);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a shared state value visible to all peers. */
|
||||
async setState(key: string, value: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_state", key, value }));
|
||||
}
|
||||
|
||||
// --- Summary / Status ---
|
||||
|
||||
/** Update this session's summary visible to other peers. */
|
||||
async setSummary(summary: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
/** Override connection status visible to peers. */
|
||||
async setStatus(status: "idle" | "working" | "dnd"): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_status", status }));
|
||||
}
|
||||
|
||||
// --- Internals ---
|
||||
|
||||
private makeReqId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
private flushOutbound(): void {
|
||||
const queued = this.outbound.slice();
|
||||
this.outbound.length = 0;
|
||||
for (const send of queued) send();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this._status = "reconnecting";
|
||||
const delay =
|
||||
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
|
||||
this.reconnectAttempt += 1;
|
||||
this.debug(`reconnect in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (this.closed) return;
|
||||
this.connect().catch((e) => {
|
||||
this.debug(
|
||||
`reconnect failed: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||
const reqId = msg._reqId as string | undefined;
|
||||
|
||||
if (msg.type === "ack") {
|
||||
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
ok: true,
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
});
|
||||
this.pendingSends.delete(pending.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "peers_list") {
|
||||
const peers = (msg.peers as PeerInfo[]) ?? [];
|
||||
this.resolveFromMap(this.listPeersResolvers, reqId, peers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "push") {
|
||||
void this.handlePush(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "state_result") {
|
||||
if (msg.key) {
|
||||
this.resolveFromMap(this.stateResolvers, reqId, {
|
||||
key: String(msg.key),
|
||||
value: msg.value,
|
||||
updatedBy: String(msg.updatedBy ?? ""),
|
||||
updatedAt: String(msg.updatedAt ?? ""),
|
||||
});
|
||||
} else {
|
||||
this.resolveFromMap(this.stateResolvers, reqId, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "state_change") {
|
||||
this.emit("state_change", {
|
||||
key: String(msg.key ?? ""),
|
||||
value: msg.value,
|
||||
updatedBy: String(msg.updatedBy ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
||||
const id = msg.id ? String(msg.id) : null;
|
||||
if (id) {
|
||||
const pending = this.pendingSends.get(id);
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
ok: false,
|
||||
error: `${msg.code}: ${msg.message}`,
|
||||
});
|
||||
this.pendingSends.delete(id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePush(msg: Record<string, unknown>): Promise<void> {
|
||||
const nonce = String(msg.nonce ?? "");
|
||||
const ciphertext = String(msg.ciphertext ?? "");
|
||||
const senderPubkey = String(msg.senderPubkey ?? "");
|
||||
|
||||
const kind: InboundMessage["kind"] = senderPubkey ? "direct" : "unknown";
|
||||
let plaintext: string | null = null;
|
||||
|
||||
// Try crypto_box decryption for direct messages
|
||||
if (senderPubkey && nonce && ciphertext) {
|
||||
plaintext = await decryptDirect(
|
||||
{ nonce, ciphertext },
|
||||
senderPubkey,
|
||||
this.sessionSecretKey ?? this.opts.secretKey,
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast/channel fallback: base64 UTF-8 decode
|
||||
if (plaintext === null && ciphertext && !senderPubkey) {
|
||||
try {
|
||||
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try base64 decode even for direct (handles broadcasts
|
||||
// and key mismatches gracefully)
|
||||
if (plaintext === null && ciphertext) {
|
||||
try {
|
||||
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||
if (
|
||||
/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) &&
|
||||
decoded.length > 0
|
||||
) {
|
||||
plaintext = decoded;
|
||||
}
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
|
||||
const push: InboundMessage = {
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
meshId: String(msg.meshId ?? ""),
|
||||
senderPubkey,
|
||||
priority: (msg.priority as Priority) ?? "next",
|
||||
nonce,
|
||||
ciphertext,
|
||||
createdAt: String(msg.createdAt ?? ""),
|
||||
receivedAt: new Date().toISOString(),
|
||||
plaintext,
|
||||
kind,
|
||||
...(msg.subtype
|
||||
? { subtype: msg.subtype as "reminder" | "system" }
|
||||
: {}),
|
||||
...(msg.event ? { event: String(msg.event) } : {}),
|
||||
...(msg.eventData
|
||||
? { eventData: msg.eventData as Record<string, unknown> }
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.emit("message", push);
|
||||
|
||||
// Emit peer_joined / peer_left convenience events
|
||||
if (push.event === "peer_joined" && push.eventData) {
|
||||
this.emit("peer_joined", push.eventData as unknown as PeerInfo);
|
||||
}
|
||||
if (push.event === "peer_left" && push.eventData) {
|
||||
this.emit("peer_left", push.eventData as unknown as PeerInfo);
|
||||
}
|
||||
}
|
||||
|
||||
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 debug(msg: string): void {
|
||||
if (this.opts.debug) console.error(`[claudemesh-sdk] ${msg}`);
|
||||
}
|
||||
}
|
||||
136
packages/sdk/src/crypto.ts
Normal file
136
packages/sdk/src/crypto.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Cryptographic primitives for the claudemesh SDK.
|
||||
*
|
||||
* Uses libsodium-wrappers for ed25519 keypair generation, hello signing,
|
||||
* and crypto_box direct-message encryption. This matches the CLI's crypto
|
||||
* implementation exactly, ensuring wire-level compatibility.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
let ready = false;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
if (!ready) {
|
||||
await sodium.ready;
|
||||
ready = true;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
/** An ed25519 keypair with hex-encoded keys. */
|
||||
export interface Ed25519Keypair {
|
||||
/** 32-byte public key, hex-encoded. */
|
||||
publicKey: string;
|
||||
/** 64-byte secret key (seed || publicKey), hex-encoded. */
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
/** Generate a fresh ed25519 keypair for use as mesh identity. */
|
||||
export async function generateKeyPair(): Promise<Ed25519Keypair> {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_sign_keypair();
|
||||
return {
|
||||
publicKey: s.to_hex(kp.publicKey),
|
||||
secretKey: s.to_hex(kp.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a hello handshake message.
|
||||
*
|
||||
* Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}`
|
||||
* Must match the broker's `canonicalHello()` exactly.
|
||||
*/
|
||||
export async function signHello(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
pubkey: string,
|
||||
secretKeyHex: string,
|
||||
): Promise<{ timestamp: number; signature: string }> {
|
||||
const s = await ensureSodium();
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||
const sig = s.crypto_sign_detached(
|
||||
s.from_string(canonical),
|
||||
s.from_hex(secretKeyHex),
|
||||
);
|
||||
return { timestamp, signature: s.to_hex(sig) };
|
||||
}
|
||||
|
||||
/** Encrypted envelope wire format. */
|
||||
export interface Envelope {
|
||||
nonce: string; // base64
|
||||
ciphertext: string; // base64
|
||||
}
|
||||
|
||||
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** Check whether a targetSpec is a hex pubkey (direct message target). */
|
||||
export function isDirectTarget(targetSpec: string): boolean {
|
||||
return HEX_PUBKEY.test(targetSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext message for a single recipient using crypto_box.
|
||||
*
|
||||
* Ed25519 keys are converted to X25519 on the fly for Diffie-Hellman.
|
||||
*/
|
||||
export async function encryptDirect(
|
||||
message: string,
|
||||
recipientPubkeyHex: string,
|
||||
senderSecretKeyHex: string,
|
||||
): Promise<Envelope> {
|
||||
const s = await ensureSodium();
|
||||
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(
|
||||
s.from_hex(recipientPubkeyHex),
|
||||
);
|
||||
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(
|
||||
s.from_hex(senderSecretKeyHex),
|
||||
);
|
||||
const nonce = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||
const ciphertext = s.crypto_box_easy(
|
||||
s.from_string(message),
|
||||
nonce,
|
||||
recipientPub,
|
||||
senderSec,
|
||||
);
|
||||
return {
|
||||
nonce: s.to_base64(nonce, s.base64_variants.ORIGINAL),
|
||||
ciphertext: s.to_base64(ciphertext, s.base64_variants.ORIGINAL),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an inbound envelope from a known sender using crypto_box_open.
|
||||
* Returns null if decryption fails.
|
||||
*/
|
||||
export async function decryptDirect(
|
||||
envelope: Envelope,
|
||||
senderPubkeyHex: string,
|
||||
recipientSecretKeyHex: string,
|
||||
): Promise<string | null> {
|
||||
const s = await ensureSodium();
|
||||
try {
|
||||
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(
|
||||
s.from_hex(senderPubkeyHex),
|
||||
);
|
||||
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(
|
||||
s.from_hex(recipientSecretKeyHex),
|
||||
);
|
||||
const nonce = s.from_base64(envelope.nonce, s.base64_variants.ORIGINAL);
|
||||
const ciphertext = s.from_base64(
|
||||
envelope.ciphertext,
|
||||
s.base64_variants.ORIGINAL,
|
||||
);
|
||||
const plain = s.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
senderPub,
|
||||
recipientSec,
|
||||
);
|
||||
return s.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
packages/sdk/src/index.ts
Normal file
9
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { MeshClient } from "./client.js";
|
||||
export { generateKeyPair } from "./crypto.js";
|
||||
export type {
|
||||
PeerInfo,
|
||||
InboundMessage,
|
||||
Priority,
|
||||
ConnStatus,
|
||||
MeshClientOptions,
|
||||
} from "./types.js";
|
||||
64
packages/sdk/src/types.ts
Normal file
64
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/** Priority levels for message delivery. */
|
||||
export type Priority = "now" | "next" | "low";
|
||||
|
||||
/** Connection status of the client. */
|
||||
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||
|
||||
/** Information about a connected peer. */
|
||||
export interface PeerInfo {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
cwd?: string;
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
channel?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** An inbound message received from the broker. */
|
||||
export interface InboundMessage {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
/** Decrypted plaintext. null if decryption failed or broadcast. */
|
||||
plaintext: string | null;
|
||||
/** Message kind: "direct" (crypto_box), "broadcast", "channel", or "unknown". */
|
||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||
/** Optional semantic tag. */
|
||||
subtype?: "reminder" | "system";
|
||||
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||
event?: string;
|
||||
/** Structured payload for the event. */
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Options for constructing a MeshClient. */
|
||||
export interface MeshClientOptions {
|
||||
/** WebSocket URL of the broker (e.g. "wss://ic.claudemesh.com/ws"). */
|
||||
brokerUrl: string;
|
||||
/** Mesh ID to join. */
|
||||
meshId: string;
|
||||
/** Member ID within the mesh. */
|
||||
memberId: string;
|
||||
/** Ed25519 public key (hex). Used for signing the hello handshake. */
|
||||
pubkey: string;
|
||||
/** Ed25519 secret key (hex). Used for signing and encryption. */
|
||||
secretKey: string;
|
||||
/** Display name visible to other peers. */
|
||||
displayName?: string;
|
||||
/** Peer type: "ai", "human", or "connector". Defaults to "connector". */
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
/** Channel identifier (e.g. "claude-code", "custom"). */
|
||||
channel?: string;
|
||||
/** Enable debug logging to stderr. */
|
||||
debug?: boolean;
|
||||
}
|
||||
13
packages/sdk/tsconfig.json
Normal file
13
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
980
pnpm-lock.yaml
generated
980
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user