13 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
025a53a70c docs: update vision — 17 of 23 items implemented, add telemetry idea
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:00:37 +01:00
Alejandro Gutiérrez
b55cf269a4 feat: implement inbound webhooks for external service integration
Add the webhook handler module (webhooks.ts) that verifies secrets
against the mesh.webhook table and broadcasts incoming HTTP POST
payloads to all connected mesh peers. This completes the webhook
feature whose schema, types, WS CRUD handlers, and CLI tools were
added in the previous commits.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:49:37 +01:00
37 changed files with 6033 additions and 27 deletions

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

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

View File

@@ -39,6 +39,7 @@ import {
meshMember as memberTable,
meshMemory,
meshState,
meshSkill,
meshStream,
meshTask,
messageQueue,
@@ -704,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 ---
/**

View File

@@ -18,7 +18,7 @@ import { WebSocketServer, type WebSocket } from "ws";
import { and, eq, sql } from "drizzle-orm";
import { env } from "./env";
import { db } from "./db";
import { messageQueue, scheduledMessage as scheduledMessageTable } from "@turbostarter/db/schema/mesh";
import { messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook } from "@turbostarter/db/schema/mesh";
import {
claimTask,
completeTask,
@@ -65,6 +65,10 @@ import {
meshSchema,
createStream,
listStreams,
shareSkill,
getSkill,
listSkills,
removeSkill,
} from "./broker";
import { ensureBucket, meshBucketName, minioClient } from "./minio";
import { qdrant, meshCollectionName, ensureCollection } from "./qdrant";
@@ -81,6 +85,8 @@ import { TokenBucket } from "./rate-limit";
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
import { buildInfo } from "./build-info";
import { verifyHelloSignature } from "./crypto";
import { handleWebhook } from "./webhooks";
import { audit, loadLastHashes, ensureAuditLogTable, verifyChain, queryAuditLog } from "./audit";
const PORT = env.BROKER_PORT;
const WS_PATH = "/ws";
@@ -99,6 +105,20 @@ interface PeerConn {
channel?: string;
model?: string;
groups: Array<{ name: string; role?: string }>;
stats?: {
messagesIn?: number;
messagesOut?: number;
toolCalls?: number;
uptime?: number;
errors?: number;
};
visible: boolean;
profile: {
avatar?: string;
title?: string;
bio?: string;
capabilities?: string[];
};
}
const connections = new Map<string, PeerConn>();
@@ -107,6 +127,75 @@ const connectionsPerMesh = new Map<string, number>();
// Stream subscriptions: "meshId:streamName" → Set of presenceIds
const streamSubscriptions = new Map<string, Set<string>>();
// --- Simulation clock state (per-mesh) ---
interface MeshClock {
speed: number;
paused: boolean;
tick: number;
simTimeMs: number;
realStartMs: number;
timer: ReturnType<typeof setInterval> | null;
}
const meshClocks = new Map<string, MeshClock>();
function broadcastClockTick(meshId: string, clock: MeshClock): void {
clock.tick++;
clock.simTimeMs += 60_000;
const tickMsg: WSPushMessage = {
type: "push",
subtype: "system" as const,
event: "tick",
eventData: { tick: clock.tick, simTime: new Date(clock.simTimeMs).toISOString(), speed: clock.speed },
messageId: crypto.randomUUID(),
meshId,
senderPubkey: "system",
priority: "low",
nonce: "",
ciphertext: "",
createdAt: new Date().toISOString(),
};
for (const [pid, peer] of connections) {
if (peer.meshId !== meshId) continue;
sendToPeer(pid, tickMsg);
}
}
function startClockInterval(meshId: string, clock: MeshClock): void {
if (clock.timer) clearInterval(clock.timer);
const intervalMs = 60_000 / clock.speed;
clock.timer = setInterval(() => broadcastClockTick(meshId, clock), intervalMs);
}
function makeClockStatus(clock: MeshClock, reqId?: string): WSServerMessage {
return {
type: "clock_status",
speed: clock.speed,
paused: clock.paused,
tick: clock.tick,
simTime: new Date(clock.simTimeMs).toISOString(),
startedAt: new Date(clock.realStartMs).toISOString(),
...(reqId ? { _reqId: reqId } : {}),
} as WSServerMessage;
}
// --- MCP proxy registry (in-memory, ephemeral) ---
interface McpRegisteredServer {
meshId: string;
presenceId: string;
serverName: string;
description: string;
tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
hostedByName: string;
}
/** Keyed by "meshId:serverName" */
const mcpRegistry = new Map<string, McpRegisteredServer>();
/** Pending MCP call forwards: callId → { resolve, timer } */
const mcpCallResolvers = new Map<string, {
resolve: (result: { result?: unknown; error?: string }) => void;
timer: ReturnType<typeof setTimeout>;
}>();
/// Scheduled messages: meshId → Map<scheduledId, entry>
interface ScheduledEntry {
id: string;
@@ -366,6 +455,13 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return;
}
// Inbound webhook: POST /hook/:meshId/:secret
const webhookMatch = req.method === "POST" && req.url?.match(/^\/hook\/([^/]+)\/([^/]+)$/);
if (webhookMatch) {
handleWebhookPost(req, res, webhookMatch[1]!, webhookMatch[2]!, started);
return;
}
res.writeHead(404);
res.end("not found");
log.debug("http", { route, status: 404, latency_ms: Date.now() - started });
@@ -657,6 +753,64 @@ function handleUploadPost(
});
}
/**
* Broadcast a push message to all connected peers in a mesh.
* Returns the number of peers the message was delivered to.
*/
function broadcastToMesh(meshId: string, msg: WSPushMessage): number {
let count = 0;
for (const [pid, peer] of connections) {
if (peer.meshId !== meshId) continue;
sendToPeer(pid, msg);
count++;
}
return count;
}
function handleWebhookPost(
req: IncomingMessage,
res: ServerResponse,
meshId: string,
secret: string,
started: number,
): void {
const chunks: Buffer[] = [];
let total = 0;
let aborted = false;
req.on("data", (chunk: Buffer) => {
if (aborted) return;
total += chunk.length;
if (total > env.MAX_MESSAGE_BYTES) {
aborted = true;
writeJson(res, 413, { ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", async () => {
if (aborted) return;
try {
const body = JSON.parse(Buffer.concat(chunks).toString());
const result = await handleWebhook(meshId, secret, body, broadcastToMesh);
writeJson(res, result.status, result.body);
log.info("webhook", {
route: `POST /hook/${meshId}/***`,
status: result.status,
delivered: result.body.delivered,
latency_ms: Date.now() - started,
});
} catch (e) {
writeJson(res, 400, { ok: false, error: "invalid JSON" });
log.warn("webhook parse error", {
error: e instanceof Error ? e.message : String(e),
});
}
});
}
function handleUpgrade(
wss: WebSocketServer,
req: IncomingMessage,
@@ -770,8 +924,14 @@ async function handleHello(
channel: hello.channel,
model: hello.model,
groups: initialGroups,
visible: true,
profile: {},
});
incMeshCount(hello.meshId);
void audit(hello.meshId, "peer_joined", member.id, effectiveDisplayName, {
pubkey: hello.pubkey,
groups: initialGroups,
});
log.info("ws hello", {
mesh_id: hello.meshId,
member: effectiveDisplayName,
@@ -800,6 +960,10 @@ async function handleSend(
nonce: msg.nonce,
ciphertext: msg.ciphertext,
});
void audit(conn.meshId, "message_sent", conn.memberId, conn.displayName, {
targetSpec: msg.targetSpec,
priority: msg.priority,
});
const ack: WSServerMessage = {
type: "ack",
id: msg.id ?? "",
@@ -842,9 +1006,11 @@ async function handleSend(
if (peer.meshId !== conn.meshId) continue;
if (isBroadcast) {
// broadcast — deliver to everyone
// broadcast — skip hidden peers
if (!peer.visible) continue;
} else if (groupName) {
// group routing — deliver only if peer is in the group
// group routing — deliver only if peer is in the group; skip hidden
if (!peer.visible) continue;
if (!peer.groups.some((g) => g.name === groupName)) continue;
} else {
// direct routing — match by pubkey
@@ -955,7 +1121,13 @@ function handleConnection(ws: WebSocket): void {
}
const resp: WSServerMessage = {
type: "peers_list",
peers: peers.map((p) => {
peers: peers
.filter((p) => {
const pc = connByPubkey.get(p.pubkey);
if (pc && !pc.visible && pc.memberPubkey !== conn.memberPubkey) return false;
return true;
})
.map((p) => {
const pc = connByPubkey.get(p.pubkey);
return {
pubkey: p.pubkey,
@@ -969,6 +1141,9 @@ function handleConnection(ws: WebSocket): void {
...(pc?.peerType ? { peerType: pc.peerType } : {}),
...(pc?.channel ? { channel: pc.channel } : {}),
...(pc?.model ? { model: pc.model } : {}),
...(pc?.stats ? { stats: pc.stats } : {}),
...(pc ? { visible: pc.visible } : {}),
...(pc?.profile && Object.keys(pc.profile).length > 0 ? { profile: pc.profile } : {}),
};
}),
...(_reqId ? { _reqId } : {}),
@@ -990,6 +1165,54 @@ function handleConnection(ws: WebSocket): void {
});
break;
}
case "set_stats": {
const sm = msg as Extract<WSClientMessage, { type: "set_stats" }>;
conn.stats = sm.stats ?? {};
log.info("ws set_stats", {
presence_id: presenceId,
stats: conn.stats,
});
break;
}
case "set_visible": {
const sv = msg as Extract<WSClientMessage, { type: "set_visible" }>;
conn.visible = sv.visible;
// Broadcast visibility change to peers in same mesh
const visEvent: WSPushMessage = {
type: "push",
subtype: "system",
event: sv.visible ? "peer_visible" : "peer_hidden",
eventData: {
name: conn.displayName,
pubkey: conn.sessionPubkey ?? conn.memberPubkey,
},
messageId: crypto.randomUUID(),
meshId: conn.meshId,
senderPubkey: "system",
priority: "low",
nonce: "",
ciphertext: "",
createdAt: new Date().toISOString(),
};
for (const [pid, peer] of connections) {
if (pid === presenceId) continue;
if (peer.meshId !== conn.meshId) continue;
sendToPeer(pid, visEvent);
}
conn.ws.send(JSON.stringify({ type: "ack", id: _reqId ?? "", messageId: "", queued: false, ...(_reqId ? { _reqId } : {}) }));
log.info("ws set_visible", { presence_id: presenceId, visible: sv.visible });
break;
}
case "set_profile": {
const sp = msg as Extract<WSClientMessage, { type: "set_profile" }>;
if (sp.avatar !== undefined) conn.profile.avatar = sp.avatar;
if (sp.title !== undefined) conn.profile.title = sp.title;
if (sp.bio !== undefined) conn.profile.bio = sp.bio;
if (sp.capabilities !== undefined) conn.profile.capabilities = sp.capabilities;
conn.ws.send(JSON.stringify({ type: "ack", id: _reqId ?? "", messageId: "", queued: false, ...(_reqId ? { _reqId } : {}) }));
log.info("ws set_profile", { presence_id: presenceId, profile: conn.profile });
break;
}
case "join_group": {
const jg = msg as Extract<WSClientMessage, { type: "join_group" }>;
const updatedGroups = await joinGroup(presenceId, jg.name, jg.role);
@@ -1029,6 +1252,10 @@ function handleConnection(ws: WebSocket): void {
presenceId,
displayName,
);
void audit(conn.meshId, "state_set", conn.memberId, conn.displayName, {
key: ss.key,
value: ss.value,
});
// Push state_change to ALL other peers in the same mesh.
for (const [pid, peer] of connections) {
if (pid === presenceId) continue;
@@ -2138,6 +2365,480 @@ function handleConnection(ws: WebSocket): void {
log.info("ws cancel_scheduled", { presence_id: presenceId, scheduled_id: cs.scheduledId, ok });
break;
}
// --- Audit log ---
case "audit_query": {
const aq = msg as Extract<WSClientMessage, { type: "audit_query" }>;
try {
const result = await queryAuditLog(conn.meshId, {
limit: aq.limit,
offset: aq.offset,
eventType: aq.eventType,
});
sendToPeer(presenceId, {
type: "audit_result",
entries: result.entries,
total: result.total,
...(_reqId ? { _reqId } : {}),
});
} catch (e) {
sendError(conn.ws, "audit_query_error", e instanceof Error ? e.message : String(e), undefined, _reqId);
}
log.info("ws audit_query", { presence_id: presenceId, mesh_id: conn.meshId });
break;
}
case "audit_verify": {
try {
const result = await verifyChain(conn.meshId);
sendToPeer(presenceId, {
type: "audit_verify_result",
valid: result.valid,
entries: result.entries,
...(result.brokenAt !== undefined ? { brokenAt: result.brokenAt } : {}),
...(_reqId ? { _reqId } : {}),
});
} catch (e) {
sendError(conn.ws, "audit_verify_error", e instanceof Error ? e.message : String(e), undefined, _reqId);
}
log.info("ws audit_verify", { presence_id: presenceId, mesh_id: conn.meshId });
break;
}
// --- Simulation clock ---
case "set_clock": {
const sc = msg as Extract<WSClientMessage, { type: "set_clock" }>;
const speed = Math.max(1, Math.min(100, Number(sc.speed) || 1));
let clock = meshClocks.get(conn.meshId);
if (!clock) {
clock = {
speed,
paused: false,
tick: 0,
simTimeMs: Date.now(),
realStartMs: Date.now(),
timer: null,
};
meshClocks.set(conn.meshId, clock);
} else {
clock.speed = speed;
}
if (!clock.paused) {
startClockInterval(conn.meshId, clock);
}
sendToPeer(presenceId, makeClockStatus(clock, _reqId));
log.info("ws set_clock", { presence_id: presenceId, mesh_id: conn.meshId, speed });
break;
}
case "pause_clock": {
const clock = meshClocks.get(conn.meshId);
if (clock) {
clock.paused = true;
if (clock.timer) { clearInterval(clock.timer); clock.timer = null; }
}
sendToPeer(presenceId, clock
? makeClockStatus(clock, _reqId)
: { type: "error", code: "no_clock", message: "No clock running for this mesh", ...(_reqId ? { _reqId } : {}) } as WSServerMessage);
log.info("ws pause_clock", { presence_id: presenceId, mesh_id: conn.meshId });
break;
}
case "resume_clock": {
const clock = meshClocks.get(conn.meshId);
if (clock && clock.paused) {
clock.paused = false;
startClockInterval(conn.meshId, clock);
}
sendToPeer(presenceId, clock
? makeClockStatus(clock, _reqId)
: { type: "error", code: "no_clock", message: "No clock running for this mesh", ...(_reqId ? { _reqId } : {}) } as WSServerMessage);
log.info("ws resume_clock", { presence_id: presenceId, mesh_id: conn.meshId });
break;
}
case "get_clock": {
const clock = meshClocks.get(conn.meshId);
sendToPeer(presenceId, clock
? makeClockStatus(clock, _reqId)
: { type: "clock_status", speed: 0, paused: true, tick: 0, simTime: new Date().toISOString(), startedAt: new Date().toISOString(), ...(_reqId ? { _reqId } : {}) } as WSServerMessage);
log.info("ws get_clock", { presence_id: presenceId, mesh_id: conn.meshId });
break;
}
// --- MCP proxy ---
case "mcp_register": {
const mr = msg as Extract<WSClientMessage, { type: "mcp_register" }>;
const regKey = `${conn.meshId}:${mr.serverName}`;
mcpRegistry.set(regKey, {
meshId: conn.meshId,
presenceId: presenceId,
serverName: mr.serverName,
description: mr.description,
tools: mr.tools,
hostedByName: conn.displayName,
});
sendToPeer(presenceId, {
type: "mcp_register_ack",
serverName: mr.serverName,
toolCount: mr.tools.length,
...(_reqId ? { _reqId } : {}),
});
log.info("ws mcp_register", {
presence_id: presenceId,
server: mr.serverName,
tools: mr.tools.length,
});
break;
}
case "mcp_unregister": {
const mu = msg as Extract<WSClientMessage, { type: "mcp_unregister" }>;
const unregKey = `${conn.meshId}:${mu.serverName}`;
const entry = mcpRegistry.get(unregKey);
if (entry && entry.presenceId === presenceId) {
mcpRegistry.delete(unregKey);
}
log.info("ws mcp_unregister", {
presence_id: presenceId,
server: mu.serverName,
});
break;
}
case "mcp_list": {
const servers: Array<{
name: string;
description: string;
hostedBy: string;
tools: Array<{ name: string; description: string }>;
}> = [];
for (const [, entry] of mcpRegistry) {
if (entry.meshId !== conn.meshId) continue;
servers.push({
name: entry.serverName,
description: entry.description,
hostedBy: entry.hostedByName,
tools: entry.tools.map((t) => ({ name: t.name, description: t.description })),
});
}
sendToPeer(presenceId, {
type: "mcp_list_result",
servers,
...(_reqId ? { _reqId } : {}),
});
log.info("ws mcp_list", {
presence_id: presenceId,
count: servers.length,
});
break;
}
case "mcp_call": {
const mc = msg as Extract<WSClientMessage, { type: "mcp_call" }>;
const callKey = `${conn.meshId}:${mc.serverName}`;
const server = mcpRegistry.get(callKey);
if (!server) {
sendToPeer(presenceId, {
type: "mcp_call_result",
error: `MCP server "${mc.serverName}" not found in mesh`,
...(_reqId ? { _reqId } : {}),
});
break;
}
// Check hosting peer is still connected
const hostConn = connections.get(server.presenceId);
if (!hostConn) {
mcpRegistry.delete(callKey);
sendToPeer(presenceId, {
type: "mcp_call_result",
error: `MCP server "${mc.serverName}" host disconnected`,
...(_reqId ? { _reqId } : {}),
});
break;
}
// Forward the call to the hosting peer
const callId = crypto.randomUUID();
const callPromise = new Promise<{ result?: unknown; error?: string }>((resolve) => {
const timer = setTimeout(() => {
if (mcpCallResolvers.delete(callId)) {
resolve({ error: "MCP call timed out (30s)" });
}
}, 30_000);
mcpCallResolvers.set(callId, { resolve, timer });
});
sendToPeer(server.presenceId, {
type: "mcp_call_forward",
callId,
serverName: mc.serverName,
toolName: mc.toolName,
args: mc.args,
callerName: conn.displayName,
});
// Wait for response from hosting peer
const callResult = await callPromise;
sendToPeer(presenceId, {
type: "mcp_call_result",
...(callResult.result !== undefined ? { result: callResult.result } : {}),
...(callResult.error ? { error: callResult.error } : {}),
...(_reqId ? { _reqId } : {}),
});
log.info("ws mcp_call", {
presence_id: presenceId,
server: mc.serverName,
tool: mc.toolName,
ok: !callResult.error,
});
break;
}
case "mcp_call_response": {
const mcr = msg as Extract<WSClientMessage, { type: "mcp_call_response" }>;
const resolver = mcpCallResolvers.get(mcr.callId);
if (resolver) {
clearTimeout(resolver.timer);
mcpCallResolvers.delete(mcr.callId);
resolver.resolve({
...(mcr.result !== undefined ? { result: mcr.result } : {}),
...(mcr.error ? { error: mcr.error } : {}),
});
}
break;
}
// --- Skills ---
case "share_skill": {
const sk = msg as Extract<WSClientMessage, { type: "share_skill" }>;
const memberInfo = conn.memberPubkey
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
: null;
await shareSkill(
conn.meshId,
sk.name,
sk.description,
sk.instructions,
sk.tags ?? [],
memberInfo?.id,
memberInfo?.displayName,
);
sendToPeer(presenceId, {
type: "skill_ack",
name: sk.name,
action: "shared",
...(_reqId ? { _reqId } : {}),
});
log.info("ws share_skill", { presence_id: presenceId, name: sk.name });
break;
}
case "get_skill": {
const gs = msg as Extract<WSClientMessage, { type: "get_skill" }>;
const skill = await getSkill(conn.meshId, gs.name);
sendToPeer(presenceId, {
type: "skill_data",
skill: skill
? {
name: skill.name,
description: skill.description,
instructions: skill.instructions,
tags: skill.tags,
author: skill.author,
createdAt: skill.createdAt.toISOString(),
}
: null,
...(_reqId ? { _reqId } : {}),
});
log.info("ws get_skill", { presence_id: presenceId, name: gs.name, found: !!skill });
break;
}
case "list_skills": {
const ls = msg as Extract<WSClientMessage, { type: "list_skills" }>;
const skills = await listSkills(conn.meshId, ls.query);
sendToPeer(presenceId, {
type: "skill_list",
skills: skills.map((s) => ({
name: s.name,
description: s.description,
tags: s.tags,
author: s.author,
createdAt: s.createdAt.toISOString(),
})),
...(_reqId ? { _reqId } : {}),
});
log.info("ws list_skills", { presence_id: presenceId, query: ls.query ?? "", count: skills.length });
break;
}
case "remove_skill": {
const rs = msg as Extract<WSClientMessage, { type: "remove_skill" }>;
const removed = await removeSkill(conn.meshId, rs.name);
sendToPeer(presenceId, {
type: "skill_ack",
name: rs.name,
action: removed ? "removed" : "not_found",
...(_reqId ? { _reqId } : {}),
});
log.info("ws remove_skill", { presence_id: presenceId, name: rs.name, removed });
break;
}
// --- Peer file sharing relay ---
case "peer_file_request": {
const fr = msg as Extract<WSClientMessage, { type: "peer_file_request" }>;
let targetPid: string | null = null;
for (const [pid, peer] of connections) {
if (peer.meshId !== conn.meshId) continue;
if (peer.memberPubkey === fr.targetPubkey || peer.sessionPubkey === fr.targetPubkey) {
targetPid = pid;
break;
}
}
if (!targetPid) {
sendError(conn.ws, "peer_not_found", "target peer not connected", undefined, _reqId);
break;
}
sendToPeer(targetPid, {
type: "peer_file_request_forward",
requesterPubkey: conn.sessionPubkey ?? conn.memberPubkey,
filePath: fr.filePath,
...(_reqId ? { _reqId } : {}),
});
log.info("ws peer_file_request", { presence_id: presenceId, target: fr.targetPubkey.slice(0, 12), path: fr.filePath });
break;
}
case "peer_file_response": {
const fr = msg as Extract<WSClientMessage, { type: "peer_file_response" }>;
let requesterPid: string | null = null;
for (const [pid, peer] of connections) {
if (peer.meshId !== conn.meshId) continue;
if (peer.memberPubkey === fr.requesterPubkey || peer.sessionPubkey === fr.requesterPubkey) {
requesterPid = pid;
break;
}
}
if (!requesterPid) break; // requester disconnected
sendToPeer(requesterPid, {
type: "peer_file_response_forward",
filePath: fr.filePath,
...(fr.content !== undefined ? { content: fr.content } : {}),
...(fr.error ? { error: fr.error } : {}),
...(_reqId ? { _reqId } : {}),
});
log.info("ws peer_file_response", { presence_id: presenceId, requester: fr.requesterPubkey.slice(0, 12), path: fr.filePath, hasError: !!fr.error });
break;
}
case "peer_dir_request": {
const dr = msg as Extract<WSClientMessage, { type: "peer_dir_request" }>;
let targetPid: string | null = null;
for (const [pid, peer] of connections) {
if (peer.meshId !== conn.meshId) continue;
if (peer.memberPubkey === dr.targetPubkey || peer.sessionPubkey === dr.targetPubkey) {
targetPid = pid;
break;
}
}
if (!targetPid) {
sendError(conn.ws, "peer_not_found", "target peer not connected", undefined, _reqId);
break;
}
sendToPeer(targetPid, {
type: "peer_dir_request_forward",
requesterPubkey: conn.sessionPubkey ?? conn.memberPubkey,
dirPath: dr.dirPath,
...(dr.pattern ? { pattern: dr.pattern } : {}),
...(_reqId ? { _reqId } : {}),
});
log.info("ws peer_dir_request", { presence_id: presenceId, target: dr.targetPubkey.slice(0, 12), path: dr.dirPath });
break;
}
case "peer_dir_response": {
const dr = msg as Extract<WSClientMessage, { type: "peer_dir_response" }>;
let requesterPid: string | null = null;
for (const [pid, peer] of connections) {
if (peer.meshId !== conn.meshId) continue;
if (peer.memberPubkey === dr.requesterPubkey || peer.sessionPubkey === dr.requesterPubkey) {
requesterPid = pid;
break;
}
}
if (!requesterPid) break;
sendToPeer(requesterPid, {
type: "peer_dir_response_forward",
dirPath: dr.dirPath,
...(dr.entries ? { entries: dr.entries } : {}),
...(dr.error ? { error: dr.error } : {}),
...(_reqId ? { _reqId } : {}),
});
log.info("ws peer_dir_response", { presence_id: presenceId, requester: dr.requesterPubkey.slice(0, 12), path: dr.dirPath });
break;
}
// --- Webhook CRUD ---
case "create_webhook": {
const cw = msg as Extract<WSClientMessage, { type: "create_webhook" }>;
if (!cw.name) {
sendError(conn.ws, "invalid_webhook", "name is required", undefined, _reqId);
break;
}
const webhookSecret = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
try {
await db.insert(meshWebhook).values({
meshId: conn.meshId,
name: cw.name,
secret: webhookSecret,
createdBy: conn.memberId,
});
} catch (dupErr: any) {
if (dupErr?.code === "23505" || dupErr?.message?.includes("unique")) {
sendError(conn.ws, "webhook_exists", `Webhook "${cw.name}" already exists in this mesh`, undefined, _reqId);
break;
}
throw dupErr;
}
const webhookUrl = `https://ic.claudemesh.com/hook/${conn.meshId}/${webhookSecret}`;
sendToPeer(presenceId, {
type: "webhook_ack",
name: cw.name,
url: webhookUrl,
secret: webhookSecret,
...(_reqId ? { _reqId } : {}),
});
log.info("ws create_webhook", { presence_id: presenceId, name: cw.name });
break;
}
case "list_webhooks": {
const whRows = await db
.select({
name: meshWebhook.name,
secret: meshWebhook.secret,
active: meshWebhook.active,
createdAt: meshWebhook.createdAt,
})
.from(meshWebhook)
.where(and(eq(meshWebhook.meshId, conn.meshId), eq(meshWebhook.active, true)));
sendToPeer(presenceId, {
type: "webhook_list",
webhooks: whRows.map((r) => ({
name: r.name,
url: `https://ic.claudemesh.com/hook/${conn.meshId}/${r.secret}`,
active: r.active,
createdAt: r.createdAt.toISOString(),
})),
...(_reqId ? { _reqId } : {}),
});
log.info("ws list_webhooks", { presence_id: presenceId, count: whRows.length });
break;
}
case "delete_webhook": {
const dw = msg as Extract<WSClientMessage, { type: "delete_webhook" }>;
await db
.update(meshWebhook)
.set({ active: false })
.where(and(eq(meshWebhook.meshId, conn.meshId), eq(meshWebhook.name, dw.name)));
sendToPeer(presenceId, {
type: "webhook_ack",
name: dw.name,
url: "",
secret: "",
...(_reqId ? { _reqId } : {}),
});
log.info("ws delete_webhook", { presence_id: presenceId, name: dw.name });
break;
}
}
} catch (e) {
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
@@ -2176,11 +2877,28 @@ function handleConnection(ws: WebSocket): void {
}
}
await disconnectPresence(presenceId);
if (conn) {
void audit(conn.meshId, "peer_left", conn.memberId, conn.displayName, {});
}
// Clean up stream subscriptions for this peer
for (const [key, subs] of streamSubscriptions) {
subs.delete(presenceId);
if (subs.size === 0) streamSubscriptions.delete(key);
}
// Clean up MCP servers registered by this peer
for (const [key, entry] of mcpRegistry) {
if (entry.presenceId === presenceId) mcpRegistry.delete(key);
}
// Auto-pause clock when mesh becomes empty
if (conn && !connectionsPerMesh.has(conn.meshId)) {
const clock = meshClocks.get(conn.meshId);
if (clock && clock.timer) {
clearInterval(clock.timer);
clock.timer = null;
clock.paused = true;
log.info("clock auto-paused (mesh empty)", { mesh_id: conn.meshId });
}
}
log.info("ws close", { presence_id: presenceId });
}
});
@@ -2394,6 +3112,15 @@ function main(): void {
startSweepers();
startDbHealth();
// Ensure audit log table exists and load hash chain state
ensureAuditLogTable()
.then(() => loadLastHashes())
.catch((e) =>
log.warn("audit log startup failed", {
error: e instanceof Error ? e.message : String(e),
}),
);
// Recover persisted scheduled messages (cron + one-shot) from DB
recoverScheduledMessages().catch((e) =>
log.warn("scheduled message recovery failed on startup", {

View File

@@ -118,6 +118,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";
@@ -199,6 +229,20 @@ export interface WSPeersListMessage {
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;
}
@@ -672,6 +716,198 @@ export interface WSStreamListMessage {
_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. */
export interface WSErrorMessage {
type: "error";
@@ -681,6 +917,85 @@ export interface WSErrorMessage {
_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. */
@@ -753,6 +1068,8 @@ export type WSClientMessage =
| WSSetStatusMessage
| WSListPeersMessage
| WSSetSummaryMessage
| WSSetVisibleMessage
| WSSetProfileMessage
| WSJoinGroupMessage
| WSLeaveGroupMessage
| WSSetStateMessage
@@ -789,9 +1106,100 @@ export type WSClientMessage =
| WSUnsubscribeMessage
| WSListStreamsMessage
| WSMeshInfoMessage
| WSSetClockMessage
| WSPauseClockMessage
| WSResumeClockMessage
| WSGetClockMessage
| WSScheduleMessage
| WSListScheduledMessage
| WSCancelScheduledMessage;
| 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
@@ -827,4 +1235,20 @@ export type WSServerMessage =
| WSScheduledAckMessage
| WSScheduledListMessage
| WSCancelScheduledAckMessage
| WSMcpRegisterAckMessage
| WSMcpListResultMessage
| WSMcpCallResultMessage
| WSMcpCallForwardMessage
| WSClockStatusMessage
| WSSkillAckMessage
| WSSkillDataMessage
| WSSkillListMessage
| WSWebhookAckMessage
| WSWebhookListMessage
| WSPeerFileRequestForwardMessage
| WSPeerFileResponseForwardMessage
| WSPeerDirRequestForwardMessage
| WSPeerDirResponseForwardMessage
| WSAuditResultMessage
| WSAuditVerifyResultMessage
| WSErrorMessage;

View File

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

View File

@@ -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");
@@ -451,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:"),

View File

@@ -158,6 +158,8 @@ If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder
| 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. |
@@ -194,6 +196,12 @@ If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder
| 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\`).
@@ -260,6 +268,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.",
@@ -328,7 +342,10 @@ Your message mode is "${messageMode}".
if (p.model) meta.push(`model:${p.model}`);
const metaStr = meta.length ? ` {${meta.join(", ")}}` : "";
const cwdStr = p.cwd ? ` cwd:${p.cwd}` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr}${metaStr} (${p.pubkey.slice(0, 12)}…)${cwdStr}${summary}`;
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}]${hiddenTag}${groupsStr}${metaStr} (${p.pubkey.slice(0, 12)}…)${cwdStr}${summary}`;
});
sections.push(`${header}\n${peerLines.join("\n")}`);
}
@@ -389,6 +406,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);
@@ -888,6 +924,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);
@@ -909,6 +1002,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[];
@@ -959,6 +1119,55 @@ 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);
@@ -989,6 +1198,109 @@ Your message mode is "${messageMode}".
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;
}
}
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(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);
}
@@ -1017,7 +1329,12 @@ Your message mode is "${messageMode}".
const eventName = msg.event;
const data = msg.eventData ?? {};
let content: string;
if (eventName === "peer_joined") {
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_left") {
content = `[system] Peer "${data.name ?? "unknown"}" left the mesh`;

View File

@@ -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:
@@ -609,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",
@@ -625,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"],
},
},
];

View File

@@ -38,6 +38,20 @@ export interface PeerInfo {
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[];
};
}
export interface InboundPush {
@@ -95,11 +109,25 @@ export class BrokerClient {
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private grantFileAccessResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
private peerFileResponseResolvers = new Map<string, { resolve: (result: { content?: string; error?: string }) => void; timer: NodeJS.Timeout }>();
private peerDirResponseResolvers = new Map<string, { resolve: (result: { entries?: string[]; error?: string }) => void; timer: NodeJS.Timeout }>();
/** Directories from which this peer serves files. Default: [process.cwd()]. */
private sharedDirs: string[] = [process.cwd()];
private closed = false;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
// --- Stats counters ---
private _statsCounters = {
messagesIn: 0,
messagesOut: 0,
toolCalls: 0,
errors: 0,
};
private _sessionStartedAt = Date.now();
private _statsReportTimer: NodeJS.Timeout | null = null;
constructor(
private mesh: JoinedMesh,
private opts: {
@@ -202,6 +230,7 @@ export class BrokerClient {
this.setConnStatus("open");
this.reconnectAttempt = 0;
this.flushOutbound();
this.startStatsReporting();
resolve();
return;
}
@@ -254,6 +283,8 @@ export class BrokerClient {
ciphertext = Buffer.from(message, "utf-8").toString("base64");
}
this._statsCounters.messagesOut++;
return new Promise((resolve) => {
if (this.pendingSends.size >= MAX_QUEUED) {
resolve({ ok: false, error: "outbound queue full" });
@@ -319,6 +350,18 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "set_status", status }));
}
/** Toggle visibility in the mesh. Hidden peers don't appear in list_peers and skip broadcasts. */
async setVisible(visible: boolean): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "set_visible", visible }));
}
/** Set public profile metadata visible to other peers. */
async setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "set_profile", ...profile }));
}
/** Request the list of connected peers from the broker. */
async listPeers(): Promise<PeerInfo[]> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
@@ -337,6 +380,42 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
}
/** Report resource usage stats to the broker. */
setStats(stats?: Record<string, number>): void {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
const payload = stats ?? {
...this._statsCounters,
uptime: Math.round((Date.now() - this._sessionStartedAt) / 1000),
};
this.ws.send(JSON.stringify({ type: "set_stats", stats: payload }));
}
/** Increment the tool call counter. */
incrementToolCalls(): void {
this._statsCounters.toolCalls++;
}
/** Increment the error counter. */
incrementErrors(): void {
this._statsCounters.errors++;
}
/** Start auto-reporting stats every 60 seconds. */
startStatsReporting(): void {
if (this._statsReportTimer) return;
this._statsReportTimer = setInterval(() => {
this.setStats();
}, 60_000);
}
/** Stop auto-reporting stats. */
stopStatsReporting(): void {
if (this._statsReportTimer) {
clearInterval(this._statsReportTimer);
this._statsReportTimer = null;
}
}
/** Join a group with an optional role. */
async joinGroup(name: string, role?: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
@@ -486,6 +565,11 @@ export class BrokerClient {
private scheduledAckResolvers = new Map<string, { resolve: (result: { scheduledId: string; deliverAt: number } | null) => void; timer: NodeJS.Timeout }>();
private scheduledListResolvers = new Map<string, { resolve: (messages: Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) => void; timer: NodeJS.Timeout }>();
private cancelScheduledResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
private mcpRegisterResolvers = new Map<string, { resolve: (result: { serverName: string; toolCount: number } | null) => void; timer: NodeJS.Timeout }>();
private mcpListResolvers = new Map<string, { resolve: (servers: Array<{ name: string; description: string; hostedBy: string; tools: Array<{ name: string; description: string }> }>) => void; timer: NodeJS.Timeout }>();
private mcpCallResolvers = new Map<string, { resolve: (result: { result?: unknown; error?: string }) => void; timer: NodeJS.Timeout }>();
/** Handler for inbound mcp_call_forward messages. Set by the MCP server. */
private mcpCallForwardHandler: ((forward: { callId: string; serverName: string; toolName: string; args: Record<string, unknown>; callerName: string }) => Promise<{ result?: unknown; error?: string }>) | null = null;
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
@@ -822,10 +906,118 @@ export class BrokerClient {
return () => this.stateChangeHandlers.delete(handler);
}
// --- MCP proxy ---
/** Register an MCP server with the mesh. */
async mcpRegister(
serverName: string,
description: string,
tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>,
): Promise<{ serverName: string; toolCount: number } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.mcpRegisterResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.mcpRegisterResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "mcp_register", serverName, description, tools, _reqId: reqId }));
});
}
/** Unregister an MCP server from the mesh. */
async mcpUnregister(serverName: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "mcp_unregister", serverName }));
}
/** List MCP servers available in the mesh. */
async mcpList(): Promise<Array<{ name: string; description: string; hostedBy: string; tools: Array<{ name: string; description: string }> }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.mcpListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.mcpListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "mcp_list", _reqId: reqId }));
});
}
/** Call a tool on a mesh-registered MCP server. 30s timeout. */
async mcpCall(serverName: string, toolName: string, args: Record<string, unknown>): Promise<{ result?: unknown; error?: string }> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return { error: "not connected" };
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.mcpCallResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.mcpCallResolvers.delete(reqId)) resolve({ error: "MCP call timed out (30s)" });
}, 30_000) });
this.ws!.send(JSON.stringify({ type: "mcp_call", serverName, toolName, args, _reqId: reqId }));
});
}
/** Set the handler for inbound forwarded MCP calls. */
onMcpCallForward(handler: (forward: { callId: string; serverName: string; toolName: string; args: Record<string, unknown>; callerName: string }) => Promise<{ result?: unknown; error?: string }>): void {
this.mcpCallForwardHandler = handler;
}
/** Send a response to a forwarded MCP call back to the broker. */
private sendMcpCallResponse(callId: string, result?: unknown, error?: string): void {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "mcp_call_response", callId, result, error }));
}
// --- Mesh info ---
private meshInfoResolvers = new Map<string, { resolve: (result: Record<string, unknown> | null) => void; timer: NodeJS.Timeout }>();
private clockStatusResolvers = new Map<string, { resolve: (result: { speed: number; paused: boolean; tick: number; simTime: string; startedAt: string } | null) => void; timer: NodeJS.Timeout }>();
async meshInfo(): Promise<Record<string, unknown> | null> {
/** Set the simulation clock speed. Returns clock status. */
async setClock(speed: number): Promise<{ speed: number; paused: boolean; tick: number; simTime: string; startedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.clockStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.clockStatusResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "set_clock", speed, _reqId: reqId }));
});
}
/** Pause the simulation clock. Returns clock status. */
async pauseClock(): Promise<{ speed: number; paused: boolean; tick: number; simTime: string; startedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.clockStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.clockStatusResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "pause_clock", _reqId: reqId }));
});
}
/** Resume the simulation clock. Returns clock status. */
async resumeClock(): Promise<{ speed: number; paused: boolean; tick: number; simTime: string; startedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.clockStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.clockStatusResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "resume_clock", _reqId: reqId }));
});
}
/** Get current simulation clock status. */
async getClock(): Promise<{ speed: number; paused: boolean; tick: number; simTime: string; startedAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.clockStatusResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.clockStatusResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "get_clock", _reqId: reqId }));
});
}
async meshInfo(): Promise<Record<string, unknown> | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
@@ -836,8 +1028,140 @@ export class BrokerClient {
});
}
// --- Skills ---
private skillAckResolvers = new Map<string, { resolve: (result: { name: string; action: string } | null) => void; timer: NodeJS.Timeout }>();
private skillDataResolvers = new Map<string, { resolve: (skill: { name: string; description: string; instructions: string; tags: string[]; author: string; createdAt: string } | null) => void; timer: NodeJS.Timeout }>();
private skillListResolvers = new Map<string, { resolve: (skills: Array<{ name: string; description: string; tags: string[]; author: string; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
/** Publish a reusable skill to the mesh. */
async shareSkill(name: string, description: string, instructions: string, tags?: string[]): Promise<{ ok: boolean; action?: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.skillAckResolvers.set(reqId, { resolve: (result) => {
resolve(result ? { ok: true, action: result.action } : null);
}, timer: setTimeout(() => {
if (this.skillAckResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "share_skill", name, description, instructions, tags, _reqId: reqId }));
});
}
/** Load a skill's full instructions by name. */
async getSkill(name: string): Promise<{ name: string; description: string; instructions: string; tags: string[]; author: string; createdAt: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.skillDataResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.skillDataResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "get_skill", name, _reqId: reqId }));
});
}
/** Browse available skills in the mesh. */
async listSkills(query?: string): Promise<Array<{ name: string; description: string; tags: string[]; author: string; createdAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.skillListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.skillListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_skills", query, _reqId: reqId }));
});
}
/** Remove a skill you published. */
async removeSkill(name: string): Promise<boolean> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.skillAckResolvers.set(reqId, { resolve: (result) => {
resolve(result?.action === "removed");
}, timer: setTimeout(() => {
if (this.skillAckResolvers.delete(reqId)) resolve(false);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "remove_skill", name, _reqId: reqId }));
});
}
// --- Webhooks ---
private webhookAckResolvers = new Map<string, { resolve: (result: { name: string; url: string; secret: string } | null) => void; timer: NodeJS.Timeout }>();
private webhookListResolvers = new Map<string, { resolve: (webhooks: Array<{ name: string; url: string; active: boolean; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
/** Create an inbound webhook. Returns the URL and secret. */
async createWebhook(name: string): Promise<{ name: string; url: string; secret: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.webhookAckResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.webhookAckResolvers.delete(reqId)) resolve(null);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "create_webhook", name, _reqId: reqId }));
});
}
/** List active webhooks for this mesh. */
async listWebhooks(): Promise<Array<{ name: string; url: string; active: boolean; createdAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.webhookListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.webhookListResolvers.delete(reqId)) resolve([]);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "list_webhooks", _reqId: reqId }));
});
}
/** Deactivate a webhook by name. */
async deleteWebhook(name: string): Promise<boolean> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.webhookAckResolvers.set(reqId, { resolve: () => resolve(true), timer: setTimeout(() => {
if (this.webhookAckResolvers.delete(reqId)) resolve(false);
}, 5_000) });
this.ws!.send(JSON.stringify({ type: "delete_webhook", name, _reqId: reqId }));
});
}
// --- Peer file sharing ---
/** Set the directories this peer shares. Default: [cwd]. */
setSharedDirs(dirs: string[]): void {
this.sharedDirs = dirs.map(d => {
const { resolve } = require("node:path");
return resolve(d);
});
}
/** Request a file from another peer's local filesystem. Returns base64 content or error. */
async requestFile(targetPubkey: string, filePath: string): Promise<{ content?: string; error?: string }> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return { error: "not connected" };
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.peerFileResponseResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.peerFileResponseResolvers.delete(reqId)) resolve({ error: "timeout waiting for peer response" });
}, 15_000) });
this.ws!.send(JSON.stringify({ type: "peer_file_request", targetPubkey, filePath, _reqId: reqId }));
});
}
/** Request a directory listing from another peer. */
async requestDir(targetPubkey: string, dirPath: string, pattern?: string): Promise<{ entries?: string[]; error?: string }> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return { error: "not connected" };
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.peerDirResponseResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.peerDirResponseResolvers.delete(reqId)) resolve({ error: "timeout waiting for peer response" });
}, 15_000) });
this.ws!.send(JSON.stringify({ type: "peer_dir_request", targetPubkey, dirPath, ...(pattern ? { pattern } : {}), _reqId: reqId }));
});
}
close(): void {
this.closed = true;
this.stopStatsReporting();
if (this.helloTimer) clearTimeout(this.helloTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
@@ -850,6 +1174,158 @@ export class BrokerClient {
this.setConnStatus("closed");
}
// --- Peer file request handlers (serving local files to remote peers) ---
private static readonly MAX_FILE_SIZE = 1_048_576; // 1MB
/** Handle an inbound file request from another peer (forwarded by broker). */
private async handlePeerFileRequest(msg: { requesterPubkey: string; filePath: string; _reqId?: string }): Promise<void> {
const { resolve, join, normalize } = await import("node:path");
const { readFileSync, statSync } = await import("node:fs");
const reqId = msg._reqId;
const sendResponse = (content?: string, error?: string) => {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({
type: "peer_file_response",
requesterPubkey: msg.requesterPubkey,
filePath: msg.filePath,
...(content !== undefined ? { content } : {}),
...(error ? { error } : {}),
...(reqId ? { _reqId: reqId } : {}),
}));
};
// Security: reject path traversal
if (msg.filePath.includes("..")) {
sendResponse(undefined, "path traversal not allowed");
return;
}
// Resolve against shared directories
let resolvedPath: string | null = null;
for (const dir of this.sharedDirs) {
const candidate = resolve(join(dir, msg.filePath));
const normalizedCandidate = normalize(candidate);
const normalizedDir = normalize(dir);
if (normalizedCandidate.startsWith(normalizedDir + "/") || normalizedCandidate === normalizedDir) {
resolvedPath = candidate;
break;
}
}
if (!resolvedPath) {
sendResponse(undefined, "file outside shared directories");
return;
}
try {
const stat = statSync(resolvedPath);
if (!stat.isFile()) {
sendResponse(undefined, "not a file");
return;
}
if (stat.size > BrokerClient.MAX_FILE_SIZE) {
sendResponse(undefined, `file too large (${stat.size} bytes, max ${BrokerClient.MAX_FILE_SIZE})`);
return;
}
const content = readFileSync(resolvedPath);
sendResponse(content.toString("base64"));
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
if (errMsg.includes("ENOENT")) {
sendResponse(undefined, "file not found");
} else {
sendResponse(undefined, `read error: ${errMsg}`);
}
}
}
/** Handle an inbound directory listing request from another peer. */
private async handlePeerDirRequest(msg: { requesterPubkey: string; dirPath: string; pattern?: string; _reqId?: string }): Promise<void> {
const { resolve, join, normalize, relative } = await import("node:path");
const { readdirSync, statSync } = await import("node:fs");
const reqId = msg._reqId;
const sendResponse = (entries?: string[], error?: string) => {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({
type: "peer_dir_response",
requesterPubkey: msg.requesterPubkey,
dirPath: msg.dirPath,
...(entries ? { entries } : {}),
...(error ? { error } : {}),
...(reqId ? { _reqId: reqId } : {}),
}));
};
const dirPath = msg.dirPath || ".";
// Security: reject path traversal
if (dirPath.includes("..")) {
sendResponse(undefined, "path traversal not allowed");
return;
}
let resolvedPath: string | null = null;
for (const dir of this.sharedDirs) {
const candidate = resolve(join(dir, dirPath));
const normalizedCandidate = normalize(candidate);
const normalizedDir = normalize(dir);
if (normalizedCandidate.startsWith(normalizedDir + "/") || normalizedCandidate === normalizedDir) {
resolvedPath = candidate;
break;
}
}
if (!resolvedPath) {
sendResponse(undefined, "directory outside shared directories");
return;
}
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
sendResponse(undefined, "not a directory");
return;
}
// Collect entries recursively (up to 2 levels, max 500 entries)
const entries: string[] = [];
const MAX_ENTRIES = 500;
const MAX_DEPTH = 2;
const pattern = msg.pattern ? new RegExp(msg.pattern.replace(/\*/g, ".*").replace(/\?/g, "."), "i") : null;
const walk = (dir: string, depth: number) => {
if (entries.length >= MAX_ENTRIES || depth > MAX_DEPTH) return;
try {
const items = readdirSync(dir, { withFileTypes: true });
for (const item of items) {
if (entries.length >= MAX_ENTRIES) break;
if (item.name.startsWith(".")) continue; // skip hidden
const relPath = relative(resolvedPath!, join(dir, item.name));
const label = item.isDirectory() ? relPath + "/" : relPath;
if (pattern && !pattern.test(item.name)) {
// If directory, still recurse (pattern may match children)
if (item.isDirectory()) walk(join(dir, item.name), depth + 1);
continue;
}
entries.push(label);
if (item.isDirectory()) walk(join(dir, item.name), depth + 1);
}
} catch { /* permission errors, etc. */ }
};
walk(resolvedPath, 0);
sendResponse(entries.sort());
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
if (errMsg.includes("ENOENT")) {
sendResponse(undefined, "directory not found");
} else {
sendResponse(undefined, `read error: ${errMsg}`);
}
}
}
// --- Internals ---
private resolveFromMap<T>(
@@ -896,6 +1372,7 @@ export class BrokerClient {
return;
}
if (msg.type === "push") {
this._statsCounters.messagesIn++;
const nonce = String(msg.nonce ?? "");
const ciphertext = String(msg.ciphertext ?? "");
const senderPubkey = String(msg.senderPubkey ?? "");
@@ -1117,10 +1594,34 @@ export class BrokerClient {
}
return;
}
if (msg.type === "clock_status") {
this.resolveFromMap(this.clockStatusResolvers, msgReqId, {
speed: Number(msg.speed ?? 0),
paused: Boolean(msg.paused),
tick: Number(msg.tick ?? 0),
simTime: String(msg.simTime ?? ""),
startedAt: String(msg.startedAt ?? ""),
});
return;
}
if (msg.type === "mesh_info_result") {
this.resolveFromMap(this.meshInfoResolvers, msgReqId, msg as Record<string, unknown>);
return;
}
if (msg.type === "skill_ack") {
this.resolveFromMap(this.skillAckResolvers, msgReqId, { name: String(msg.name ?? ""), action: String(msg.action ?? "") });
return;
}
if (msg.type === "skill_data") {
const skill = msg.skill as { name: string; description: string; instructions: string; tags: string[]; author: string; createdAt: string } | null;
this.resolveFromMap(this.skillDataResolvers, msgReqId, skill ?? null);
return;
}
if (msg.type === "skill_list") {
const skills = (msg.skills as Array<{ name: string; description: string; tags: string[]; author: string; createdAt: string }>) ?? [];
this.resolveFromMap(this.skillListResolvers, msgReqId, skills);
return;
}
if (msg.type === "scheduled_ack") {
this.resolveFromMap(this.scheduledAckResolvers, msgReqId, {
scheduledId: String(msg.scheduledId ?? ""),
@@ -1138,6 +1639,78 @@ export class BrokerClient {
this.resolveFromMap(this.cancelScheduledResolvers, msgReqId, Boolean(msg.ok));
return;
}
if (msg.type === "mcp_register_ack") {
this.resolveFromMap(this.mcpRegisterResolvers, msgReqId, {
serverName: String(msg.serverName ?? ""),
toolCount: Number(msg.toolCount ?? 0),
});
return;
}
if (msg.type === "mcp_list_result") {
const servers = (msg.servers as Array<{ name: string; description: string; hostedBy: string; tools: Array<{ name: string; description: string }> }>) ?? [];
this.resolveFromMap(this.mcpListResolvers, msgReqId, servers);
return;
}
if (msg.type === "mcp_call_result") {
this.resolveFromMap(this.mcpCallResolvers, msgReqId, {
...(msg.result !== undefined ? { result: msg.result } : {}),
...(msg.error ? { error: String(msg.error) } : {}),
});
return;
}
if (msg.type === "mcp_call_forward") {
const forward = {
callId: String(msg.callId ?? ""),
serverName: String(msg.serverName ?? ""),
toolName: String(msg.toolName ?? ""),
args: (msg.args as Record<string, unknown>) ?? {},
callerName: String(msg.callerName ?? ""),
};
if (this.mcpCallForwardHandler) {
this.mcpCallForwardHandler(forward)
.then((res) => this.sendMcpCallResponse(forward.callId, res.result, res.error))
.catch((e) => this.sendMcpCallResponse(forward.callId, undefined, e instanceof Error ? e.message : String(e)));
} else {
this.sendMcpCallResponse(forward.callId, undefined, "No MCP call handler registered on this peer");
}
return;
}
// --- Peer file sharing handlers ---
if (msg.type === "peer_file_request_forward") {
void this.handlePeerFileRequest(msg as { requesterPubkey: string; filePath: string; _reqId?: string });
return;
}
if (msg.type === "peer_file_response_forward") {
this.resolveFromMap(this.peerFileResponseResolvers, msgReqId, {
content: msg.content ? String(msg.content) : undefined,
error: msg.error ? String(msg.error) : undefined,
});
return;
}
if (msg.type === "peer_dir_request_forward") {
void this.handlePeerDirRequest(msg as { requesterPubkey: string; dirPath: string; pattern?: string; _reqId?: string });
return;
}
if (msg.type === "peer_dir_response_forward") {
this.resolveFromMap(this.peerDirResponseResolvers, msgReqId, {
entries: (msg.entries as string[] | undefined) ?? undefined,
error: msg.error ? String(msg.error) : undefined,
});
return;
}
if (msg.type === "webhook_ack") {
this.resolveFromMap(this.webhookAckResolvers, msgReqId, {
name: String(msg.name ?? ""),
url: String(msg.url ?? ""),
secret: String(msg.secret ?? ""),
});
return;
}
if (msg.type === "webhook_list") {
const webhooks = (msg.webhooks as Array<{ name: string; url: string; active: boolean; createdAt: string }>) ?? [];
this.resolveFromMap(this.webhookListResolvers, msgReqId, webhooks);
return;
}
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null;
@@ -1184,6 +1757,17 @@ export class BrokerClient {
[this.streamCreatedResolvers, null],
[this.listPeersResolvers, []],
[this.meshInfoResolvers, null],
[this.clockStatusResolvers, null],
[this.mcpRegisterResolvers, null],
[this.mcpListResolvers, []],
[this.mcpCallResolvers, { error: "broker error" }],
[this.skillAckResolvers, null],
[this.skillDataResolvers, null],
[this.skillListResolvers, []],
[this.peerFileResponseResolvers, { error: "broker error" }],
[this.peerDirResponseResolvers, { error: "broker error" }],
[this.webhookAckResolvers, null],
[this.webhookListResolvers, []],
];
for (const [map, defaultVal] of allMaps) {
const first = (map as Map<string, any>).entries().next().value as [string, { resolve: (v: unknown) => void; timer: NodeJS.Timeout }] | undefined;

View File

@@ -16,6 +16,8 @@ import {
} 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",
@@ -68,6 +70,10 @@ export default async function LiveMeshPage({
<PeerGraphPanel meshId={id} />
<LiveStreamPanel meshId={id} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<StateTimelinePanel meshId={id} />
<ResourcePanel meshId={id} />
</div>
</>
);
}

View File

@@ -0,0 +1,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>
);
};

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

View File

@@ -285,6 +285,44 @@ Control which peers can see each other. Instead of a flat mesh where everyone se
**Effort:** 2-3 days.
### 21. Semantic peer search
In large meshes (50+ peers), scanning `list_peers` output is noise. A `search_peers` tool that filters and ranks by multiple dimensions:
- **Structured filters:** name, group, role, status, peerType, channel, model, cwd
- **Free-text search:** matches against peer summaries, profile bios, capabilities, and shared skills
- **Capability matching:** "find a peer that knows about database migrations" searches across profile capabilities + skills catalog + recent summaries
- **Ranking:** peers with more matching dimensions rank higher; active (idle/working) peers rank above DND/offline
**MCP tool:** `search_peers(query, filters?)` — returns a ranked list of matching peers with relevance scores.
**Implementation:** Broker-side — accepts a `search_peers` message, runs multi-field matching against the in-memory peer list + skills table. No external search engine needed for <500 peers; for larger meshes, wire into the existing Qdrant vector store (already available via `vector_search`).
**Effort:** Half day.
### 22. Mesh telemetry and debugging
A structured logging system where peers report errors, warnings, and debug info to the broker. Goes beyond the audit log (which tracks events) — this tracks operational health.
**What peers report:**
- Errors: tool failures, connection drops, unhandled exceptions
- Warnings: high context usage, slow responses, retry patterns
- Debug: decision traces, task reasoning, why a particular approach was chosen
- Performance: response latency per tool call, message round-trip times
**Broker storage:** Structured logs indexed by mesh, peer, timestamp, severity. Retained for N days (configurable). Queryable via WS messages.
**AI self-analysis:** Peers query their own logs to identify patterns: "I've hit this error 3 times in the last hour — what's common?" The mesh becomes self-diagnosing. Leads can query team-wide logs: "Which peers are seeing errors in the deploy flow?"
**Reporting:** Aggregated metrics per peer, per mesh, per time window. Error rates, common failure modes, response time percentiles. Surfaced in the dashboard or via `mesh_report(timeframe: "24h")`.
**MCP tools:**
- `mesh_log(level, message, data?)` — report a log entry
- `mesh_logs(query?, peer?, level?, last?)` — query logs
- `mesh_report(timeframe?)` — aggregated health report
**Effort:** 1-2 days.
---
## Suggested build order
@@ -296,19 +334,24 @@ Control which peers can see each other. Instead of a flat mesh where everyone se
| 3 | System notifications | 2 hours | Reactive mesh, awareness | **DONE** `453705a` |
| 4 | Cron reminders | 2 hours | Persistent scheduling | **DONE** `e873807` |
| 5 | Mesh templates | Half day | Better onboarding | **DONE** `69e93d4` |
| 6 | Default personal mesh | Half day | Zero-config start | |
| 7 | Inbound webhooks | Half day | External integrations | |
| 8 | Skills catalog | 1 day | Knowledge marketplace | |
| 9 | Shared project files | 1 day | Cross-session file access | |
| 10 | Slack connector | 1-2 days | Reach beyond Claude Code | |
| 11 | Mesh MCP proxy | 2-3 days | Dynamic tools without restart | |
| 12 | Dashboard (real-time) | 2-3 days | Visual situational awareness | **PARTIAL** `59332dc` |
| 6 | Default personal mesh | Half day | Zero-config start | **DONE** `b0dc538` |
| 7 | Inbound webhooks | Half day | External integrations | **DONE** `b55cf26` |
| 8 | Skills catalog | 1 day | Knowledge marketplace | **DONE** `c8cb1e3` |
| 9 | Shared project files | 1 day | Cross-session file access | **DONE** `504111c` |
| 10 | Slack connector | 1-2 days | Reach beyond Claude Code | **DONE** `5563f90` |
| 11 | Mesh MCP proxy | 2-3 days | Dynamic tools without restart | **DONE** `08e289a` |
| 12 | Dashboard (real-time) | 2-3 days | Visual situational awareness | **DONE** `59332dc` + `7d432b3` |
| 13 | Human peers (web chat) | 2-3 days | Humans in the loop | |
| 14 | Simulation clock (heartbeat x1-x100) | 2 days | AI-driven load testing | |
| 14 | Simulation clock (heartbeat x1-x100) | 2 days | AI-driven load testing | **DONE** `05d9b56` |
| 15 | Sandboxes (E2B) | 2-3 days | Shared compute | |
| 16 | Signed audit log | 3-5 days | Trust, compliance | |
| 16 | Signed audit log | 3-5 days | Trust, compliance | **DONE** `86a2583` |
| 17 | Bridge / federation | 1-2 weeks | Multi-mesh coordination | |
| 18 | Peer visibility + spatial topology | 2-3 days | Simulation fog-of-war, org scoping | |
| 18 | Peer visibility + profiles | 2-3 days | Simulation fog-of-war, org scoping | **DONE** (types.ts/index.ts) |
| 19 | Semantic peer search | Half day | Discovery in large meshes | |
| 20 | Peer stats reporting | Half day | Resource awareness, load balancing | **DONE** `b3b9972` |
| 21 | SDK (@claudemesh/sdk) | 1 day | Non-Claude-Code clients | **DONE** `7e102a2` |
| 22 | Telegram connector | 1-2 days | Reach beyond Claude Code | **DONE** `fe92853` |
| 23 | Mesh telemetry + debugging | 1-2 days | Self-diagnosing mesh | |
---

View 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.

View 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/web-api": "^7.0.0",
"@slack/socket-mode": "^2.0.0",
"ws": "^8.0.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
"devDependencies": {
"@types/ws": "^8.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"license": "MIT"
}

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

View 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",
};
}

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

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

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

View 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"]
}

View 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

View 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": {
"ws": "^8.0.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
"devDependencies": {
"@types/ws": "^8.0.0",
"typescript": "^5.0.0"
}
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View 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",
};
}

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

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

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

View 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"]
}

View File

@@ -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(),
});
@@ -427,11 +436,73 @@ 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()
@@ -659,3 +730,19 @@ export const selectMeshStreamSchema = createSelectSchema(meshStream);
export const insertMeshStreamSchema = createInsertSchema(meshStream);
export type SelectMeshStream = typeof meshStream.$inferSelect;
export type InsertMeshStream = typeof meshStream.$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
View 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
View 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
View 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
View 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;
}
}

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

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"declaration": true,
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}