Compare commits
13 Commits
27c9d2a02c
...
025a53a70c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025a53a70c | ||
|
|
b55cf269a4 | ||
|
|
504111c50c | ||
|
|
05d9b56f28 | ||
|
|
c8cb1e3ea5 | ||
|
|
86a258301f | ||
|
|
7e102a235b | ||
|
|
5563f90733 | ||
|
|
b3b9972e60 | ||
|
|
fe9285351b | ||
|
|
08e289a5e3 | ||
|
|
7d432b3aaa | ||
|
|
b0dc538119 |
215
apps/broker/src/audit.ts
Normal file
215
apps/broker/src/audit.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Signed audit log with hash-chain integrity.
|
||||
*
|
||||
* Every significant mesh event is recorded as an append-only entry.
|
||||
* Each entry's SHA-256 hash includes the previous entry's hash,
|
||||
* forming a tamper-evident chain per mesh. If any row is modified
|
||||
* or deleted, all subsequent hashes will fail verification.
|
||||
*
|
||||
* NEVER logs message content (ciphertext or plaintext) — only metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { asc, desc, eq, sql, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { auditLog } from "@turbostarter/db/schema/mesh";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lastHash = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core audit logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeHash(
|
||||
prevHash: string,
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
createdAt: Date,
|
||||
): string {
|
||||
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an audit entry for a mesh event.
|
||||
*
|
||||
* Fire-and-forget safe — callers should `void audit(...)` or
|
||||
* `.catch(log.warn)` to avoid blocking the hot path.
|
||||
*/
|
||||
export async function audit(
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
actorDisplayName: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const prevHash = lastHash.get(meshId) ?? "genesis";
|
||||
const createdAt = new Date();
|
||||
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
|
||||
|
||||
try {
|
||||
await db.insert(auditLog).values({
|
||||
meshId,
|
||||
eventType,
|
||||
actorMemberId,
|
||||
actorDisplayName,
|
||||
payload,
|
||||
prevHash,
|
||||
hash,
|
||||
createdAt,
|
||||
});
|
||||
lastHash.set(meshId, hash);
|
||||
} catch (e) {
|
||||
log.warn("audit log insert failed", {
|
||||
mesh_id: meshId,
|
||||
event_type: eventType,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup: load last hash per mesh from DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadLastHashes(): Promise<void> {
|
||||
try {
|
||||
// For each mesh, find the most recent audit entry by id (serial).
|
||||
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
|
||||
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
|
||||
SELECT DISTINCT ON (mesh_id) mesh_id, hash
|
||||
FROM mesh.audit_log
|
||||
ORDER BY mesh_id, id DESC
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
lastHash.set(row.mesh_id, row.hash);
|
||||
}
|
||||
log.info("audit: loaded last hashes", { meshes: lastHash.size });
|
||||
} catch (e) {
|
||||
// Table may not exist yet on first boot — that's fine.
|
||||
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function verifyChain(
|
||||
meshId: string,
|
||||
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.meshId, meshId))
|
||||
.orderBy(asc(auditLog.id));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: true, entries: 0 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
|
||||
|
||||
// Verify prevHash linkage
|
||||
if (row.prevHash !== expectedPrevHash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
|
||||
// Recompute hash and verify
|
||||
const recomputed = computeHash(
|
||||
row.prevHash,
|
||||
row.meshId,
|
||||
row.eventType,
|
||||
row.actorMemberId,
|
||||
row.payload as Record<string, unknown>,
|
||||
row.createdAt,
|
||||
);
|
||||
if (recomputed !== row.hash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, entries: rows.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: paginated audit entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAuditLog(
|
||||
meshId: string,
|
||||
options?: { limit?: number; offset?: number; eventType?: string },
|
||||
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const conditions = [eq(auditLog.meshId, meshId)];
|
||||
if (options?.eventType) {
|
||||
conditions.push(eq(auditLog.eventType, options.eventType));
|
||||
}
|
||||
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
|
||||
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(auditLog.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLog)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
eventType: r.eventType,
|
||||
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
|
||||
payload: r.payload as Record<string, unknown>,
|
||||
hash: r.hash,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure table exists (raw DDL for first-boot before migrations run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureAuditLogTable(): Promise<void> {
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS mesh.audit_log (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_member_id TEXT,
|
||||
actor_display_name TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log.warn("audit: ensureAuditLogTable failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
97
apps/broker/src/webhooks.ts
Normal file
97
apps/broker/src/webhooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Inbound webhook handler.
|
||||
*
|
||||
* External services POST JSON to `/hook/:meshId/:secret`. The broker
|
||||
* verifies the secret against the mesh.webhook table, then pushes the
|
||||
* payload to all connected peers in that mesh as a "webhook" push.
|
||||
*/
|
||||
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshWebhook } from "@turbostarter/db/schema/mesh";
|
||||
import type { WSPushMessage } from "./types";
|
||||
import { log } from "./logger";
|
||||
|
||||
export interface WebhookResult {
|
||||
status: number;
|
||||
body: { ok: boolean; delivered?: number; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a webhook by meshId + secret, verify it's active, then return
|
||||
* the webhook name for push routing. Returns null if not found/inactive.
|
||||
*/
|
||||
async function findActiveWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
): Promise<{ id: string; name: string; meshId: string } | null> {
|
||||
const rows = await db
|
||||
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
|
||||
.from(meshWebhook)
|
||||
.where(
|
||||
and(
|
||||
eq(meshWebhook.meshId, meshId),
|
||||
eq(meshWebhook.secret, secret),
|
||||
eq(meshWebhook.active, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook HTTP request.
|
||||
*
|
||||
* @param meshId - mesh ID from the URL path
|
||||
* @param secret - webhook secret from the URL path
|
||||
* @param body - parsed JSON body from the request
|
||||
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
|
||||
* Returns the number of peers the message was delivered to.
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
body: unknown,
|
||||
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
|
||||
): Promise<WebhookResult> {
|
||||
try {
|
||||
const webhook = await findActiveWebhook(meshId, secret);
|
||||
if (!webhook) {
|
||||
log.warn("webhook auth failed", { mesh_id: meshId });
|
||||
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
|
||||
if (body === null || body === undefined || typeof body !== "object") {
|
||||
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
|
||||
}
|
||||
|
||||
const pushMsg: WSPushMessage = {
|
||||
type: "push",
|
||||
subtype: "webhook" as any,
|
||||
event: webhook.name,
|
||||
eventData: body as Record<string, unknown>,
|
||||
messageId: crypto.randomUUID(),
|
||||
meshId: webhook.meshId,
|
||||
senderPubkey: `webhook:${webhook.name}`,
|
||||
priority: "next",
|
||||
nonce: "",
|
||||
ciphertext: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
|
||||
|
||||
log.info("webhook delivered", {
|
||||
webhook_name: webhook.name,
|
||||
mesh_id: webhook.meshId,
|
||||
delivered,
|
||||
});
|
||||
|
||||
return { status: 200, body: { ok: true, delivered } };
|
||||
} catch (e) {
|
||||
log.error("webhook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return { status: 500, body: { ok: false, error: "internal error" } };
|
||||
}
|
||||
}
|
||||
@@ -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:"),
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ResourceCard {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: { id: string; text: string; sub: string }[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build resource cards from stream data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildResources = (data: GetMyMeshStreamResponse): ResourceCard[] => {
|
||||
const onlinePeers = data.presences.filter((p) => !p.disconnectedAt);
|
||||
const offlinePeers = data.presences.filter((p) => p.disconnectedAt);
|
||||
|
||||
const priorityCounts = { now: 0, next: 0, low: 0 };
|
||||
for (const e of data.envelopes) {
|
||||
priorityCounts[e.priority] = (priorityCounts[e.priority] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Unique senders
|
||||
const uniqueSenders = new Set(data.envelopes.map((e) => e.senderMemberId));
|
||||
|
||||
// Recent audit event types
|
||||
const eventTypes = new Map<string, number>();
|
||||
for (const e of data.auditEvents) {
|
||||
eventTypes.set(e.eventType, (eventTypes.get(e.eventType) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "peers",
|
||||
icon: "⬡",
|
||||
label: "Live Peers",
|
||||
count: onlinePeers.length,
|
||||
accent: "text-emerald-500",
|
||||
items: onlinePeers.slice(0, 4).map((p) => ({
|
||||
id: p.id,
|
||||
text: p.displayName ?? p.memberId.slice(0, 8),
|
||||
sub: `${p.status} · ${p.cwd.split("/").pop() ?? p.cwd}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "envelopes",
|
||||
icon: "▤",
|
||||
label: "Envelopes",
|
||||
count: data.envelopes.length,
|
||||
accent: "text-[var(--cm-clay)]",
|
||||
items: [
|
||||
{
|
||||
id: "priority-now",
|
||||
text: `${priorityCounts.now} now`,
|
||||
sub: "urgent / bypass busy",
|
||||
},
|
||||
{
|
||||
id: "priority-next",
|
||||
text: `${priorityCounts.next} next`,
|
||||
sub: "default priority",
|
||||
},
|
||||
{
|
||||
id: "priority-low",
|
||||
text: `${priorityCounts.low} low`,
|
||||
sub: "pull-only",
|
||||
},
|
||||
{
|
||||
id: "senders",
|
||||
text: `${uniqueSenders.size} unique senders`,
|
||||
sub: "across all envelopes",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
icon: "◈",
|
||||
label: "Audit Events",
|
||||
count: data.auditEvents.length,
|
||||
accent: "text-[#c46686]",
|
||||
items: Array.from(eventTypes.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([type, count]) => ({
|
||||
id: `evt-${type}`,
|
||||
text: type.replace(/_/g, " "),
|
||||
sub: `${count} occurrence${count !== 1 ? "s" : ""}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
icon: "⊡",
|
||||
label: "Sessions",
|
||||
count: data.presences.length,
|
||||
accent: "text-[var(--cm-fg-secondary)]",
|
||||
items: [
|
||||
{
|
||||
id: "online",
|
||||
text: `${onlinePeers.length} online`,
|
||||
sub: "currently connected",
|
||||
},
|
||||
{
|
||||
id: "offline",
|
||||
text: `${offlinePeers.length} offline`,
|
||||
sub: "recently disconnected",
|
||||
},
|
||||
...data.presences
|
||||
.filter((p) => p.status === "working")
|
||||
.slice(0, 2)
|
||||
.map((p) => ({
|
||||
id: `working-${p.id}`,
|
||||
text: `${p.displayName ?? p.memberId.slice(0, 8)}`,
|
||||
sub: "currently working",
|
||||
})),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ResourcePanel = ({ meshId }: { meshId: string }) => {
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const resources = useMemo(
|
||||
() => (data ? buildResources(data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
resources
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Resource cards grid */}
|
||||
<div
|
||||
className="grid grid-cols-2 gap-px bg-[var(--cm-border)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{resources.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className="flex flex-col bg-[var(--cm-bg)] p-3"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] ${card.accent}`}>
|
||||
{card.icon}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-lg font-semibold leading-none tabular-nums ${card.accent}`}>
|
||||
{card.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Recent items */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{card.items.length === 0 ? (
|
||||
<span className="text-[9px] text-[var(--cm-fg-tertiary)]">
|
||||
none
|
||||
</span>
|
||||
) : (
|
||||
card.items.map((item) => (
|
||||
<div key={item.id} className="min-w-0">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-[var(--cm-fg-secondary)] truncate">
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--cm-fg-tertiary)] truncate">
|
||||
{item.sub}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span>derived from stream data</span>
|
||||
<span>read-only snapshot</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal file
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
type: "audit" | "presence" | "envelope";
|
||||
icon: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
actor: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build timeline from stream data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
peer_connected: "connected",
|
||||
peer_disconnected: "disconnected",
|
||||
message_sent: "msg sent",
|
||||
message_delivered: "msg delivered",
|
||||
invite_created: "invite created",
|
||||
invite_redeemed: "invite redeemed",
|
||||
member_joined: "member joined",
|
||||
member_removed: "member removed",
|
||||
state_changed: "state changed",
|
||||
};
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
peer_connected: "↑",
|
||||
peer_disconnected: "↓",
|
||||
message_sent: "→",
|
||||
message_delivered: "✓",
|
||||
invite_created: "✉",
|
||||
invite_redeemed: "★",
|
||||
member_joined: "+",
|
||||
member_removed: "−",
|
||||
state_changed: "Δ",
|
||||
};
|
||||
|
||||
const buildTimeline = (data: GetMyMeshStreamResponse): TimelineEntry[] => {
|
||||
const entries: TimelineEntry[] = [];
|
||||
|
||||
// Audit events → timeline entries
|
||||
for (const e of data.auditEvents) {
|
||||
entries.push({
|
||||
id: e.id,
|
||||
timestamp: new Date(e.createdAt),
|
||||
type: "audit",
|
||||
icon: EVENT_ICONS[e.eventType] ?? "•",
|
||||
label: EVENT_LABELS[e.eventType] ?? e.eventType.replace(/_/g, " "),
|
||||
detail: [
|
||||
e.actorPeerId ? `actor:${e.actorPeerId.slice(0, 8)}` : null,
|
||||
e.targetPeerId ? `target:${e.targetPeerId.slice(0, 8)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" → ") || "—",
|
||||
actor: e.actorPeerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Presence status snapshots → timeline entries (latest status per peer)
|
||||
for (const p of data.presences) {
|
||||
entries.push({
|
||||
id: `presence-${p.id}`,
|
||||
timestamp: new Date(p.statusUpdatedAt),
|
||||
type: "presence",
|
||||
icon: p.status === "idle" ? "◇" : p.status === "working" ? "◆" : "◈",
|
||||
label: `${p.displayName ?? p.memberId.slice(0, 8)} → ${p.status}`,
|
||||
detail: `via ${p.statusSource} · pid ${p.pid}`,
|
||||
actor: p.memberId,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort descending (newest first)
|
||||
entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Format helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const fmtTime = (d: Date) =>
|
||||
d.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const TYPE_COLORS: Record<TimelineEntry["type"], string> = {
|
||||
audit: "text-[var(--cm-clay)]",
|
||||
presence: "text-emerald-500",
|
||||
envelope: "text-[#c46686]",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const StateTimelinePanel = ({ meshId }: { meshId: string }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const entries = useMemo(
|
||||
() => (data ? buildTimeline(data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
// Auto-scroll to top (newest) on new data
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [entries.length]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
event timeline
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{entries.length} events ·{" "}
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline body */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[420px] overflow-y-auto scrollbar-thin"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||
No events recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative px-4 py-3">
|
||||
{/* Vertical spine */}
|
||||
<div className="absolute left-[27px] top-3 bottom-3 w-px bg-[var(--cm-border)]" />
|
||||
|
||||
{entries.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="group relative flex items-start gap-3 py-1.5"
|
||||
>
|
||||
{/* Node dot */}
|
||||
<div className="relative z-10 flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={`text-[10px] leading-none ${TYPE_COLORS[entry.type]}`}
|
||||
>
|
||||
{entry.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)] tabular-nums">
|
||||
{fmtTime(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium ${TYPE_COLORS[entry.type]}`}>
|
||||
{entry.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-[var(--cm-fg-tertiary)] truncate">
|
||||
{entry.detail}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<span className="flex-shrink-0 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[8px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[var(--cm-clay)]">•</span>
|
||||
audit
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-emerald-500">•</span>
|
||||
presence
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[#c46686]">•</span>
|
||||
envelope
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--cm-border)]">|</span>
|
||||
<span>newest first · auto-scroll</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 | |
|
||||
|
||||
---
|
||||
|
||||
|
||||
102
packages/connector-slack/README.md
Normal file
102
packages/connector-slack/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @claudemesh/connector-slack
|
||||
|
||||
Slack connector for claudemesh -- relay messages between a Slack channel and mesh peers.
|
||||
|
||||
The connector joins the mesh as a peer with `peerType: "connector"` and `channel: "slack"`, bridging messages bidirectionally:
|
||||
|
||||
- **Slack -> Mesh**: Messages from the Slack channel are broadcast to all mesh peers, formatted as `[SlackUser via Slack #channel] message`.
|
||||
- **Mesh -> Slack**: Push messages received from mesh peers are posted to the Slack channel, formatted as `*[MeshPeerName]*: message`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Create a Slack App
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**.
|
||||
2. Name it (e.g. "claudemesh bridge") and select your workspace.
|
||||
|
||||
### 2. Configure Bot Token Scopes
|
||||
|
||||
Under **OAuth & Permissions** > **Bot Token Scopes**, add:
|
||||
|
||||
- `chat:write` -- post messages to channels
|
||||
- `channels:read` -- list public channels
|
||||
- `channels:history` -- read message history in public channels
|
||||
- `users:read` -- resolve user IDs to display names
|
||||
|
||||
### 3. Enable Socket Mode
|
||||
|
||||
Under **Socket Mode**, toggle it **on**. This generates an **App-Level Token** (`xapp-...`). You'll need this for the `SLACK_APP_TOKEN` env var.
|
||||
|
||||
Socket Mode means no public URL is required -- the connector connects outbound to Slack's WebSocket servers.
|
||||
|
||||
### 4. Subscribe to Events
|
||||
|
||||
Under **Event Subscriptions**, enable events and add the following **Bot Events**:
|
||||
|
||||
- `message.channels` -- listen for messages in public channels
|
||||
|
||||
### 5. Install the App
|
||||
|
||||
Under **Install App**, click **Install to Workspace** and authorize. Copy the **Bot User OAuth Token** (`xoxb-...`) for the `SLACK_BOT_TOKEN` env var.
|
||||
|
||||
### 6. Invite the Bot
|
||||
|
||||
Invite the bot to the channel you want to bridge:
|
||||
```
|
||||
/invite @claudemesh-bridge
|
||||
```
|
||||
|
||||
### 7. Get the Channel ID
|
||||
|
||||
Right-click the channel name in Slack > **View channel details** > copy the Channel ID at the bottom (e.g. `C0123456789`).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (`xoxb-...`) |
|
||||
| `SLACK_APP_TOKEN` | Yes | App-Level Token for Socket Mode (`xapp-...`) |
|
||||
| `SLACK_CHANNEL_ID` | Yes | Channel ID to bridge (e.g. `C0123456789`) |
|
||||
| `MESH_BROKER_URL` | Yes | Broker WebSocket URL (e.g. `wss://ic.claudemesh.com/ws`) |
|
||||
| `MESH_ID` | Yes | Mesh UUID |
|
||||
| `MESH_MEMBER_ID` | Yes | Member UUID for this connector's membership |
|
||||
| `MESH_PUBKEY` | Yes | Ed25519 public key (64 hex chars) |
|
||||
| `MESH_SECRET_KEY` | Yes | Ed25519 secret key (128 hex chars) |
|
||||
| `MESH_DISPLAY_NAME` | No | Display name visible to peers (default: `"Slack-connector"`) |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Run
|
||||
SLACK_BOT_TOKEN=xoxb-... \
|
||||
SLACK_APP_TOKEN=xapp-... \
|
||||
SLACK_CHANNEL_ID=C0123456789 \
|
||||
MESH_BROKER_URL=wss://ic.claudemesh.com/ws \
|
||||
MESH_ID=your-mesh-uuid \
|
||||
MESH_MEMBER_ID=your-member-uuid \
|
||||
MESH_PUBKEY=your-pubkey-hex \
|
||||
MESH_SECRET_KEY=your-secret-key-hex \
|
||||
MESH_DISPLAY_NAME="Slack-#general" \
|
||||
npm start
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Slack (Socket Mode) Connector claudemesh Broker
|
||||
| | |
|
||||
|-- message event -------->| |
|
||||
| |-- send (broadcast) ----->|
|
||||
| | |-- push --> peers
|
||||
| | |
|
||||
| |<---- push (from peer) ---|
|
||||
|<-- chat.postMessage -----| |
|
||||
```
|
||||
|
||||
The connector uses Socket Mode for Slack (outbound WebSocket, no public URL needed) and a standard claudemesh WS client for the mesh connection. Both connections auto-reconnect on failure.
|
||||
26
packages/connector-slack/package.json
Normal file
26
packages/connector-slack/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@claudemesh/connector-slack",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack connector for claudemesh — relay messages between Slack channels and mesh peers",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/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"
|
||||
}
|
||||
97
packages/connector-slack/src/bridge.ts
Normal file
97
packages/connector-slack/src/bridge.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Bridge — bidirectional message relay between Slack and a claudemesh mesh.
|
||||
*
|
||||
* Slack -> Mesh: messages from the Slack channel are broadcast to mesh peers.
|
||||
* Mesh -> Slack: push messages addressed to this connector (or broadcast)
|
||||
* are posted to the Slack channel.
|
||||
*/
|
||||
|
||||
import type { SlackClient } from "./slack";
|
||||
import type { MeshClient } from "./mesh-client";
|
||||
import type { SlackConnectorConfig } from "./config";
|
||||
|
||||
export class Bridge {
|
||||
private slack: SlackClient;
|
||||
private mesh: MeshClient;
|
||||
private config: SlackConnectorConfig;
|
||||
private unsubSlack: (() => void) | null = null;
|
||||
private unsubMesh: (() => void) | null = null;
|
||||
/** Track message IDs we've relayed to avoid echo loops. */
|
||||
private recentRelayed = new Set<string>();
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
slack: SlackClient,
|
||||
mesh: MeshClient,
|
||||
config: SlackConnectorConfig,
|
||||
) {
|
||||
this.slack = slack;
|
||||
this.mesh = mesh;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the bidirectional relay.
|
||||
*/
|
||||
start(): void {
|
||||
// --- Slack -> Mesh ---
|
||||
this.unsubSlack = this.slack.onMessage((msg) => {
|
||||
const channelName = this.config.slackChannelId;
|
||||
const formatted = `[${msg.displayName} via Slack #${channelName}] ${msg.text}`;
|
||||
|
||||
// Broadcast to all mesh peers
|
||||
this.mesh.broadcast(formatted).catch((err) => {
|
||||
console.error("[bridge] Failed to relay Slack->Mesh:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Mesh -> Slack ---
|
||||
this.unsubMesh = this.mesh.onPush((push) => {
|
||||
// Skip messages we ourselves sent (echo prevention)
|
||||
if (this.recentRelayed.has(push.messageId)) {
|
||||
this.recentRelayed.delete(push.messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip system events (peer_joined, peer_left) — too noisy for Slack
|
||||
if (push.subtype === "system") return;
|
||||
|
||||
const plaintext = push.plaintext;
|
||||
if (!plaintext) return;
|
||||
|
||||
// Resolve sender name from the push metadata
|
||||
const senderName = push.senderName || push.senderPubkey.slice(0, 8);
|
||||
const formatted = `*[${senderName}]*: ${plaintext}`;
|
||||
|
||||
this.slack.postMessage(formatted).catch((err) => {
|
||||
console.error("[bridge] Failed to relay Mesh->Slack:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Periodically clean the echo-prevention set to prevent memory leaks
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.recentRelayed.clear();
|
||||
}, 60_000);
|
||||
|
||||
console.log("[bridge] Relay started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the relay and clean up subscriptions.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.unsubSlack) {
|
||||
this.unsubSlack();
|
||||
this.unsubSlack = null;
|
||||
}
|
||||
if (this.unsubMesh) {
|
||||
this.unsubMesh();
|
||||
this.unsubMesh = null;
|
||||
}
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
console.log("[bridge] Relay stopped");
|
||||
}
|
||||
}
|
||||
71
packages/connector-slack/src/config.ts
Normal file
71
packages/connector-slack/src/config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Configuration types for the Slack connector.
|
||||
*
|
||||
* All values are loaded from environment variables in index.ts.
|
||||
*/
|
||||
|
||||
export interface SlackConnectorConfig {
|
||||
// Slack
|
||||
/** Bot User OAuth Token (xoxb-...) */
|
||||
slackBotToken: string;
|
||||
/** App-Level Token for Socket Mode (xapp-...) */
|
||||
slackAppToken: string;
|
||||
/** Channel ID to bridge (e.g. C0123456789) */
|
||||
slackChannelId: string;
|
||||
|
||||
// Mesh
|
||||
/** WebSocket URL of the claudemesh broker (wss://...) */
|
||||
brokerUrl: string;
|
||||
/** Mesh UUID */
|
||||
meshId: string;
|
||||
/** Member UUID (this connector's membership) */
|
||||
memberId: string;
|
||||
/** Ed25519 public key, hex-encoded (64 chars) */
|
||||
pubkey: string;
|
||||
/** Ed25519 secret key, hex-encoded (128 chars) */
|
||||
secretKey: string;
|
||||
/** Display name visible to mesh peers (e.g. "Slack-#general") */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from environment variables, throwing on any missing required var.
|
||||
*/
|
||||
export function loadConfigFromEnv(): SlackConnectorConfig {
|
||||
const required: Array<[keyof SlackConnectorConfig, string]> = [
|
||||
["slackBotToken", "SLACK_BOT_TOKEN"],
|
||||
["slackAppToken", "SLACK_APP_TOKEN"],
|
||||
["slackChannelId", "SLACK_CHANNEL_ID"],
|
||||
["brokerUrl", "MESH_BROKER_URL"],
|
||||
["meshId", "MESH_ID"],
|
||||
["memberId", "MESH_MEMBER_ID"],
|
||||
["pubkey", "MESH_PUBKEY"],
|
||||
["secretKey", "MESH_SECRET_KEY"],
|
||||
];
|
||||
|
||||
const missing: string[] = [];
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const [key, envVar] of required) {
|
||||
const val = process.env[envVar];
|
||||
if (!val) {
|
||||
missing.push(envVar);
|
||||
} else {
|
||||
values[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...(values as unknown as Omit<SlackConnectorConfig, "displayName">),
|
||||
displayName:
|
||||
process.env.MESH_DISPLAY_NAME ??
|
||||
process.env.DISPLAY_NAME ??
|
||||
"Slack-connector",
|
||||
};
|
||||
}
|
||||
77
packages/connector-slack/src/index.ts
Normal file
77
packages/connector-slack/src/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @claudemesh/connector-slack — entry point.
|
||||
*
|
||||
* Bridges a Slack channel to a claudemesh mesh, relaying messages
|
||||
* bidirectionally. The connector joins the mesh as a peer with
|
||||
* peerType: "connector" and channel: "slack".
|
||||
*
|
||||
* Usage:
|
||||
* SLACK_BOT_TOKEN=xoxb-... \
|
||||
* SLACK_APP_TOKEN=xapp-... \
|
||||
* SLACK_CHANNEL_ID=C0123456789 \
|
||||
* MESH_BROKER_URL=wss://ic.claudemesh.com/ws \
|
||||
* MESH_ID=... \
|
||||
* MESH_MEMBER_ID=... \
|
||||
* MESH_PUBKEY=... \
|
||||
* MESH_SECRET_KEY=... \
|
||||
* MESH_DISPLAY_NAME="Slack-#general" \
|
||||
* node dist/index.js
|
||||
*/
|
||||
|
||||
import { loadConfigFromEnv } from "./config";
|
||||
import { SlackClient } from "./slack";
|
||||
import { MeshClient } from "./mesh-client";
|
||||
import { Bridge } from "./bridge";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[connector-slack] Loading configuration...");
|
||||
const config = loadConfigFromEnv();
|
||||
|
||||
// --- Connect to mesh ---
|
||||
console.log(
|
||||
`[connector-slack] Connecting to mesh ${config.meshId} at ${config.brokerUrl}...`,
|
||||
);
|
||||
const mesh = new MeshClient(config);
|
||||
await mesh.connect();
|
||||
console.log(
|
||||
`[connector-slack] Mesh connected as "${config.displayName}" (peerType: connector, channel: slack)`,
|
||||
);
|
||||
mesh.setSummary(
|
||||
`Slack connector bridging channel ${config.slackChannelId} to this mesh`,
|
||||
);
|
||||
|
||||
// --- Connect to Slack ---
|
||||
console.log("[connector-slack] Connecting to Slack via Socket Mode...");
|
||||
const slack = new SlackClient(
|
||||
config.slackBotToken,
|
||||
config.slackAppToken,
|
||||
config.slackChannelId,
|
||||
);
|
||||
await slack.connect();
|
||||
console.log(
|
||||
`[connector-slack] Slack connected, listening on channel ${config.slackChannelId}`,
|
||||
);
|
||||
|
||||
// --- Start bridge ---
|
||||
const bridge = new Bridge(slack, mesh, config);
|
||||
bridge.start();
|
||||
console.log("[connector-slack] Bridge active. Relaying messages...");
|
||||
|
||||
// --- Graceful shutdown ---
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
console.log(`\n[connector-slack] Received ${signal}, shutting down...`);
|
||||
bridge.stop();
|
||||
await slack.disconnect();
|
||||
mesh.close();
|
||||
console.log("[connector-slack] Goodbye.");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[connector-slack] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
405
packages/connector-slack/src/mesh-client.ts
Normal file
405
packages/connector-slack/src/mesh-client.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Minimal WebSocket client for the claudemesh broker.
|
||||
*
|
||||
* Handles:
|
||||
* - hello handshake with ed25519 signature (peerType: "connector")
|
||||
* - send / ack message flow
|
||||
* - broadcast (targetSpec: "*")
|
||||
* - inbound push messages
|
||||
* - auto-reconnect with exponential backoff
|
||||
*
|
||||
* Kept intentionally standalone — no dependency on the CLI's BrokerClient
|
||||
* so this package can be installed and run independently.
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
import nacl from "tweetnacl";
|
||||
import naclUtil from "tweetnacl-util";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { SlackConnectorConfig } from "./config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Priority = "now" | "next" | "low";
|
||||
|
||||
export interface InboundPush {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
senderName: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
plaintext: string | null;
|
||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||
subtype?: "reminder" | "system";
|
||||
event?: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PushHandler = (push: InboundPush) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function randomId(): string {
|
||||
return randomBytes(12).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the hello handshake.
|
||||
*
|
||||
* Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}`
|
||||
* Must match the broker's canonicalHello() exactly.
|
||||
*/
|
||||
function signHello(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
pubkey: string,
|
||||
secretKeyHex: string,
|
||||
): { timestamp: number; signature: string } {
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||
const messageBytes = naclUtil.decodeUTF8(canonical);
|
||||
const secretKey = Buffer.from(secretKeyHex, "hex");
|
||||
const sig = nacl.sign.detached(messageBytes, secretKey);
|
||||
return {
|
||||
timestamp,
|
||||
signature: Buffer.from(sig).toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MeshClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
export class MeshClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: SlackConnectorConfig;
|
||||
private closed = false;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private pushHandlers = new Set<PushHandler>();
|
||||
private pushBuffer: InboundPush[] = [];
|
||||
private pendingAcks = new Map<
|
||||
string,
|
||||
{ resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void }
|
||||
>();
|
||||
private outbound: Array<() => void> = [];
|
||||
private _status: "connecting" | "open" | "closed" | "reconnecting" = "closed";
|
||||
|
||||
/** Generate a fresh ed25519 session keypair for this process. */
|
||||
private sessionKeypair = nacl.sign.keyPair();
|
||||
private sessionPubkeyHex = Buffer.from(this.sessionKeypair.publicKey).toString("hex");
|
||||
|
||||
constructor(config: SlackConnectorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get status(): string {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Connection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) throw new Error("client is closed");
|
||||
this._status = "connecting";
|
||||
|
||||
const ws = new WebSocket(this.config.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ws.on("open", () => {
|
||||
const { timestamp, signature } = signHello(
|
||||
this.config.meshId,
|
||||
this.config.memberId,
|
||||
this.config.pubkey,
|
||||
this.config.secretKey,
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.config.meshId,
|
||||
memberId: this.config.memberId,
|
||||
pubkey: this.config.pubkey,
|
||||
sessionPubkey: this.sessionPubkeyHex,
|
||||
displayName: this.config.displayName,
|
||||
sessionId: `connector-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
peerType: "connector" as const,
|
||||
channel: "slack",
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
);
|
||||
|
||||
this.helloTimer = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, HELLO_ACK_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
ws.on("message", (raw: WebSocket.RawData) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this._status = "open";
|
||||
this.reconnectAttempt = 0;
|
||||
this.flushOutbound();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleServerMessage(msg);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this.ws = null;
|
||||
if (this._status !== "open" && this._status !== "reconnecting") {
|
||||
reject(new Error("ws closed before hello_ack"));
|
||||
}
|
||||
if (!this.closed) this.scheduleReconnect();
|
||||
else this._status = "closed";
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
console.error("[mesh-client] ws error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close the connection. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this._status = "closed";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sending
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a message to a targetSpec ("*" for broadcast, pubkey hex for
|
||||
* direct, "@group" for group).
|
||||
*/
|
||||
async send(
|
||||
targetSpec: string,
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
const id = randomId();
|
||||
// Connectors send broadcasts/channels as base64 plaintext.
|
||||
// Direct crypto_box encryption is not implemented here to keep
|
||||
// the connector simple — mesh peers can still identify the sender
|
||||
// by the connector's pubkey.
|
||||
const nonce = randomBytes(24).toString("base64");
|
||||
const ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.pendingAcks.set(id, { resolve });
|
||||
|
||||
const dispatch = (): void => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (this._status === "open") {
|
||||
dispatch();
|
||||
} else {
|
||||
this.outbound.push(dispatch);
|
||||
}
|
||||
|
||||
// Ack timeout
|
||||
setTimeout(() => {
|
||||
if (this.pendingAcks.has(id)) {
|
||||
this.pendingAcks.delete(id);
|
||||
resolve({ ok: false, error: "ack timeout" });
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast a message to all mesh peers. */
|
||||
async broadcast(
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
return this.send("*", message, priority);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Push subscriptions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Subscribe to inbound push messages. Returns an unsubscribe function. */
|
||||
onPush(handler: PushHandler): () => void {
|
||||
this.pushHandlers.add(handler);
|
||||
return () => this.pushHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** Drain buffered pushes (for polling). */
|
||||
drainPushBuffer(): InboundPush[] {
|
||||
const drained = this.pushBuffer.slice();
|
||||
this.pushBuffer.length = 0;
|
||||
return drained;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Set summary / status (fire-and-forget)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
setSummary(summary: string): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
setStatus(status: "idle" | "working" | "dnd"): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_status", status }));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||
if (msg.type === "ack") {
|
||||
const pending = this.pendingAcks.get(String(msg.id ?? ""));
|
||||
if (pending) {
|
||||
pending.resolve({ ok: true, messageId: String(msg.messageId ?? "") });
|
||||
this.pendingAcks.delete(String(msg.id ?? ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "push") {
|
||||
const nonce = String(msg.nonce ?? "");
|
||||
const ciphertext = String(msg.ciphertext ?? "");
|
||||
const senderPubkey = String(msg.senderPubkey ?? "");
|
||||
|
||||
// Decode plaintext — connector receives broadcasts as base64 UTF-8.
|
||||
// Direct (crypto_box) messages from peers will fail to decrypt here
|
||||
// since we don't implement crypto_box_open. That's acceptable —
|
||||
// the connector is meant for broadcast/channel relay, not private DMs.
|
||||
let plaintext: string | null = null;
|
||||
if (ciphertext) {
|
||||
try {
|
||||
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||
// Sanity: check it looks like valid UTF-8 text
|
||||
if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) {
|
||||
plaintext = decoded;
|
||||
}
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
|
||||
const push: InboundPush = {
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
meshId: String(msg.meshId ?? ""),
|
||||
senderPubkey,
|
||||
senderName: String(
|
||||
(msg as Record<string, unknown>).senderName ??
|
||||
(msg as Record<string, unknown>).displayName ??
|
||||
senderPubkey.slice(0, 8),
|
||||
),
|
||||
priority: (msg.priority as Priority) ?? "next",
|
||||
nonce,
|
||||
ciphertext,
|
||||
createdAt: String(msg.createdAt ?? ""),
|
||||
receivedAt: new Date().toISOString(),
|
||||
plaintext,
|
||||
kind: senderPubkey ? "direct" : "unknown",
|
||||
...(msg.subtype
|
||||
? { subtype: msg.subtype as "reminder" | "system" }
|
||||
: {}),
|
||||
...(msg.event ? { event: String(msg.event) } : {}),
|
||||
...(msg.eventData
|
||||
? { eventData: msg.eventData as Record<string, unknown> }
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.pushBuffer.push(push);
|
||||
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
|
||||
|
||||
for (const h of this.pushHandlers) {
|
||||
try {
|
||||
h(push);
|
||||
} catch {
|
||||
/* handler errors are not our problem */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Other message types (peers_list, state_result, etc.) are ignored
|
||||
// by the connector — it only needs send/ack + push.
|
||||
}
|
||||
|
||||
private flushOutbound(): void {
|
||||
const queued = this.outbound.splice(0);
|
||||
for (const fn of queued) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this._status = "reconnecting";
|
||||
const delay =
|
||||
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)];
|
||||
this.reconnectAttempt++;
|
||||
console.log(
|
||||
`[mesh-client] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`,
|
||||
);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect().catch((err) => {
|
||||
console.error("[mesh-client] reconnect failed:", err.message);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
132
packages/connector-slack/src/slack.ts
Normal file
132
packages/connector-slack/src/slack.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Slack client — Socket Mode connection + Web API helpers.
|
||||
*
|
||||
* Uses Socket Mode so users do not need a public URL for Events API.
|
||||
* Listens for messages in a single configured channel and provides
|
||||
* a method to post formatted messages back.
|
||||
*/
|
||||
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { SocketModeClient } from "@slack/socket-mode";
|
||||
|
||||
export interface SlackMessage {
|
||||
/** Slack user ID (e.g. U0123456789) */
|
||||
userId: string;
|
||||
/** Resolved display name (falls back to userId if lookup fails) */
|
||||
displayName: string;
|
||||
/** Message text */
|
||||
text: string;
|
||||
/** Slack channel ID */
|
||||
channelId: string;
|
||||
/** Message timestamp (Slack's unique ID for the message) */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export type SlackMessageHandler = (msg: SlackMessage) => void;
|
||||
|
||||
export class SlackClient {
|
||||
private web: WebClient;
|
||||
private socket: SocketModeClient;
|
||||
private channelId: string;
|
||||
private userCache = new Map<string, string>();
|
||||
private handlers = new Set<SlackMessageHandler>();
|
||||
|
||||
constructor(botToken: string, appToken: string, channelId: string) {
|
||||
this.web = new WebClient(botToken);
|
||||
this.socket = new SocketModeClient({ appToken });
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Slack via Socket Mode and start listening for messages.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
// Verify the bot token works and cache the bot's own user ID
|
||||
// so we can ignore messages from ourselves.
|
||||
const authResult = await this.web.auth.test();
|
||||
const botUserId = authResult.user_id as string;
|
||||
|
||||
this.socket.on("message", async ({ event, ack }) => {
|
||||
// Always acknowledge the event to Slack
|
||||
await ack();
|
||||
|
||||
// Only process messages from the configured channel
|
||||
if (event.channel !== this.channelId) return;
|
||||
|
||||
// Ignore bot's own messages, message_changed edits, and subtypes
|
||||
// like channel_join, channel_leave, etc.
|
||||
if (event.user === botUserId) return;
|
||||
if (event.subtype) return;
|
||||
if (!event.text) return;
|
||||
|
||||
const displayName = await this.resolveUserName(event.user);
|
||||
const msg: SlackMessage = {
|
||||
userId: event.user,
|
||||
displayName,
|
||||
text: event.text,
|
||||
channelId: event.channel,
|
||||
ts: event.ts,
|
||||
};
|
||||
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
} catch {
|
||||
// Handler errors should not break the event loop
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.socket.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to the configured Slack channel.
|
||||
*/
|
||||
async postMessage(text: string): Promise<void> {
|
||||
await this.web.chat.postMessage({
|
||||
channel: this.channelId,
|
||||
text,
|
||||
// Use mrkdwn so mesh peer names can be bolded
|
||||
mrkdwn: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for incoming Slack messages.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onMessage(handler: SlackMessageHandler): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Slack user ID to a display name.
|
||||
* Results are cached for the lifetime of the process.
|
||||
*/
|
||||
async resolveUserName(userId: string): Promise<string> {
|
||||
const cached = this.userCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const result = await this.web.users.info({ user: userId });
|
||||
const name =
|
||||
result.user?.profile?.display_name ||
|
||||
result.user?.real_name ||
|
||||
result.user?.name ||
|
||||
userId;
|
||||
this.userCache.set(userId, name);
|
||||
return name;
|
||||
} catch {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Socket Mode.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
19
packages/connector-slack/tsconfig.json
Normal file
19
packages/connector-slack/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
79
packages/connector-telegram/README.md
Normal file
79
packages/connector-telegram/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @claudemesh/connector-telegram
|
||||
|
||||
Bridges a Telegram chat and a claudemesh mesh, relaying messages bidirectionally. Joins the mesh as `peerType: "connector"`, `channel: "telegram"`.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a Telegram bot
|
||||
|
||||
1. Open Telegram, search for **@BotFather**
|
||||
2. Send `/newbot`, follow the prompts
|
||||
3. Copy the bot token (e.g. `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
|
||||
|
||||
### 2. Get the chat ID
|
||||
|
||||
1. Add your bot to a group chat (or start a DM with it)
|
||||
2. Send a message in the chat
|
||||
3. Fetch updates to find the chat ID:
|
||||
```bash
|
||||
curl https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates | jq '.result[0].message.chat.id'
|
||||
```
|
||||
Group IDs are negative numbers (e.g. `-1001234567890`). DM IDs are positive.
|
||||
|
||||
### 3. Get mesh credentials
|
||||
|
||||
You need a claudemesh membership. Use the CLI to join a mesh and note the credentials, or check your mesh config file (`~/.config/claudemesh/config.json`).
|
||||
|
||||
### 4. Configure environment variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | `123456:ABC-DEF...` |
|
||||
| `TELEGRAM_CHAT_ID` | Target chat ID | `-1001234567890` |
|
||||
| `BROKER_URL` | Broker WebSocket URL | `wss://ic.claudemesh.com/ws` |
|
||||
| `MESH_ID` | Mesh UUID | `abc123-...` |
|
||||
| `MEMBER_ID` | Member UUID | `def456-...` |
|
||||
| `PUBKEY` | Ed25519 public key (hex) | `a1b2c3...` |
|
||||
| `SECRET_KEY` | Ed25519 secret key (hex) | `d4e5f6...` |
|
||||
| `DISPLAY_NAME` | Peer display name (optional) | `Telegram-DevChat` |
|
||||
|
||||
### 5. Run
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Start
|
||||
TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... BROKER_URL=wss://ic.claudemesh.com/ws \
|
||||
MESH_ID=... MEMBER_ID=... PUBKEY=... SECRET_KEY=... DISPLAY_NAME=Telegram-DevChat \
|
||||
npm start
|
||||
```
|
||||
|
||||
Or with npx (once published):
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=... npx @claudemesh/connector-telegram
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
- **Telegram -> Mesh**: Text messages from Telegram are formatted as `[SenderName] message` and broadcast to all mesh peers.
|
||||
- **Mesh -> Telegram**: Messages from mesh peers are formatted as `<b>[PeerName]</b> message` (HTML) and posted to the Telegram chat.
|
||||
- Non-text messages (photos, stickers, etc.) are skipped with a log note.
|
||||
- The connector uses long polling (no webhooks needed, no public URL required).
|
||||
- Auto-reconnects to the mesh broker with exponential backoff.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Telegram Chat <--long poll--> TelegramClient
|
||||
|
|
||||
Bridge (relay)
|
||||
|
|
||||
Mesh Broker <----WebSocket----> MeshClient
|
||||
```
|
||||
|
||||
- `src/config.ts` — Configuration types and env loader
|
||||
- `src/telegram.ts` — Telegram Bot API client (fetch + long polling)
|
||||
- `src/mesh-client.ts` — Minimal claudemesh WS client (tweetnacl for ed25519 signing)
|
||||
- `src/bridge.ts` — Bidirectional message relay
|
||||
- `src/index.ts` — Entry point, wires everything together
|
||||
20
packages/connector-telegram/package.json
Normal file
20
packages/connector-telegram/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@claudemesh/connector-telegram",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram connector for claudemesh — relay messages between Telegram chats and mesh peers",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.0.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
96
packages/connector-telegram/src/bridge.ts
Normal file
96
packages/connector-telegram/src/bridge.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Bidirectional bridge between Telegram and a claudemesh mesh.
|
||||
*
|
||||
* Telegram -> Mesh: incoming Telegram messages are formatted as
|
||||
* "[TelegramUser] message" and broadcast to the mesh.
|
||||
*
|
||||
* Mesh -> Telegram: inbound mesh pushes are formatted as
|
||||
* "[MeshPeerName] message" and posted to the Telegram chat.
|
||||
*/
|
||||
|
||||
import type { TelegramClient, TelegramMessage } from "./telegram.js";
|
||||
import type { MeshClient, InboundPush } from "./mesh-client.js";
|
||||
|
||||
export class Bridge {
|
||||
constructor(
|
||||
private telegram: TelegramClient,
|
||||
private mesh: MeshClient,
|
||||
) {}
|
||||
|
||||
/** Wire up both directions. Call once after both clients are connected. */
|
||||
start(): void {
|
||||
// Telegram -> Mesh
|
||||
this.telegram.onMessage((msg: TelegramMessage) => {
|
||||
this.handleTelegramMessage(msg);
|
||||
});
|
||||
|
||||
// Mesh -> Telegram
|
||||
this.mesh.onPush((push: InboundPush) => {
|
||||
this.handleMeshPush(push);
|
||||
});
|
||||
|
||||
console.log("[bridge] relay active");
|
||||
}
|
||||
|
||||
private handleTelegramMessage(msg: TelegramMessage): void {
|
||||
if (!msg.text) {
|
||||
// Skip non-text messages (photos, stickers, etc.)
|
||||
const type = msg.from
|
||||
? "non-text content"
|
||||
: "system message";
|
||||
console.log(`[bridge] skipping ${type} from Telegram`);
|
||||
return;
|
||||
}
|
||||
|
||||
const senderName = formatTelegramSender(msg);
|
||||
const meshMessage = `[${senderName}] ${msg.text}`;
|
||||
|
||||
console.log(`[bridge] tg->mesh: ${meshMessage.slice(0, 80)}...`);
|
||||
|
||||
// Broadcast to all mesh peers
|
||||
this.mesh.send("*", meshMessage).catch((err) => {
|
||||
console.error(`[bridge] failed to relay to mesh:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMeshPush(push: InboundPush): void {
|
||||
// Decode the message content
|
||||
const plaintext = push.plaintext ?? tryDecodeBase64(push.ciphertext);
|
||||
if (!plaintext) return;
|
||||
|
||||
// Skip messages that originated from this connector (prevent echo)
|
||||
if (push.senderPubkey === this.mesh.pubkey) return;
|
||||
|
||||
// Find the sender's display name from the push metadata
|
||||
const senderName = push.senderDisplayName || push.senderPubkey.slice(0, 8);
|
||||
const telegramMessage = `<b>[${escapeHtml(senderName)}]</b> ${escapeHtml(plaintext)}`;
|
||||
|
||||
console.log(`[bridge] mesh->tg: [${senderName}] ${plaintext.slice(0, 60)}...`);
|
||||
|
||||
this.telegram.sendMessage(telegramMessage).catch((err) => {
|
||||
console.error(`[bridge] failed to relay to Telegram:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatTelegramSender(msg: TelegramMessage): string {
|
||||
if (!msg.from) return "Unknown";
|
||||
const parts = [msg.from.first_name];
|
||||
if (msg.from.last_name) parts.push(msg.from.last_name);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function tryDecodeBase64(b64: string): string | null {
|
||||
try {
|
||||
return Buffer.from(b64, "base64").toString("utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
32
packages/connector-telegram/src/config.ts
Normal file
32
packages/connector-telegram/src/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface TelegramConnectorConfig {
|
||||
// Telegram
|
||||
telegramBotToken: string; // from @BotFather
|
||||
telegramChatId: string; // group chat or user chat ID
|
||||
|
||||
// Mesh
|
||||
brokerUrl: string;
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string;
|
||||
displayName: string; // e.g. "Telegram-DevChat"
|
||||
}
|
||||
|
||||
export function loadConfigFromEnv(): TelegramConnectorConfig {
|
||||
const required = (key: string): string => {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing required env var: ${key}`);
|
||||
return val;
|
||||
};
|
||||
|
||||
return {
|
||||
telegramBotToken: required("TELEGRAM_BOT_TOKEN"),
|
||||
telegramChatId: required("TELEGRAM_CHAT_ID"),
|
||||
brokerUrl: required("BROKER_URL"),
|
||||
meshId: required("MESH_ID"),
|
||||
memberId: required("MEMBER_ID"),
|
||||
pubkey: required("PUBKEY"),
|
||||
secretKey: required("SECRET_KEY"),
|
||||
displayName: process.env.DISPLAY_NAME || "Telegram",
|
||||
};
|
||||
}
|
||||
66
packages/connector-telegram/src/index.ts
Normal file
66
packages/connector-telegram/src/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @claudemesh/connector-telegram — Entry point
|
||||
*
|
||||
* Bridges a Telegram chat and a claudemesh mesh, relaying messages
|
||||
* bidirectionally. Joins the mesh as peerType: "connector", channel: "telegram".
|
||||
*
|
||||
* Configuration via environment variables:
|
||||
* TELEGRAM_BOT_TOKEN — Bot token from @BotFather
|
||||
* TELEGRAM_CHAT_ID — Target chat ID (group or user)
|
||||
* BROKER_URL — claudemesh broker WebSocket URL
|
||||
* MESH_ID — Mesh UUID
|
||||
* MEMBER_ID — Member UUID
|
||||
* PUBKEY — Ed25519 public key (hex)
|
||||
* SECRET_KEY — Ed25519 secret key (hex)
|
||||
* DISPLAY_NAME — Peer display name (default: "Telegram")
|
||||
*/
|
||||
|
||||
import { loadConfigFromEnv } from "./config.js";
|
||||
import { TelegramClient } from "./telegram.js";
|
||||
import { MeshClient } from "./mesh-client.js";
|
||||
import { Bridge } from "./bridge.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[connector-telegram] starting...");
|
||||
|
||||
// Load configuration
|
||||
const config = loadConfigFromEnv();
|
||||
console.log(`[connector-telegram] display name: ${config.displayName}`);
|
||||
console.log(`[connector-telegram] chat ID: ${config.telegramChatId}`);
|
||||
console.log(`[connector-telegram] broker: ${config.brokerUrl}`);
|
||||
|
||||
// Initialize clients
|
||||
const telegram = new TelegramClient(config.telegramBotToken, config.telegramChatId);
|
||||
const mesh = new MeshClient(config);
|
||||
|
||||
// Connect to mesh broker
|
||||
console.log("[connector-telegram] connecting to mesh...");
|
||||
await mesh.connect();
|
||||
console.log("[connector-telegram] mesh connected");
|
||||
|
||||
// Start Telegram long polling
|
||||
telegram.start();
|
||||
console.log("[connector-telegram] Telegram polling started");
|
||||
|
||||
// Wire up bidirectional relay
|
||||
const bridge = new Bridge(telegram, mesh);
|
||||
bridge.start();
|
||||
|
||||
console.log("[connector-telegram] bridge active — relaying messages");
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = (): void => {
|
||||
console.log("\n[connector-telegram] shutting down...");
|
||||
telegram.stop();
|
||||
mesh.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[connector-telegram] fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
259
packages/connector-telegram/src/mesh-client.ts
Normal file
259
packages/connector-telegram/src/mesh-client.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Minimal WebSocket client for connecting to a claudemesh broker.
|
||||
* Uses tweetnacl for ed25519 signing (hello handshake).
|
||||
* Stripped down from apps/cli/src/ws/client.ts — hello + send/receive only.
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
import nacl from "tweetnacl";
|
||||
import { decodeUTF8, encodeBase64 } from "tweetnacl-util";
|
||||
import type { TelegramConnectorConfig } from "./config.js";
|
||||
|
||||
export interface InboundPush {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
senderDisplayName?: string;
|
||||
priority: "now" | "next" | "low";
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
plaintext: string | null;
|
||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||
subtype?: "reminder" | "system";
|
||||
event?: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PushHandler = (msg: InboundPush) => void;
|
||||
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
export class MeshClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pushHandlers = new Set<PushHandler>();
|
||||
private closed = false;
|
||||
private reconnectAttempt = 0;
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private connected = false;
|
||||
private outbound: Array<() => void> = [];
|
||||
private peerNames = new Map<string, string>(); // pubkey -> displayName
|
||||
|
||||
readonly pubkey: string;
|
||||
|
||||
constructor(private config: TelegramConnectorConfig) {
|
||||
this.pubkey = config.pubkey;
|
||||
}
|
||||
|
||||
onPush(handler: PushHandler): void {
|
||||
this.pushHandlers.add(handler);
|
||||
}
|
||||
|
||||
/** Open WS, send hello, resolve when hello_ack received. */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) throw new Error("client is closed");
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(this.config.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on("open", () => {
|
||||
console.log("[mesh] ws open, sending hello");
|
||||
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${this.config.meshId}|${this.config.memberId}|${this.config.pubkey}|${timestamp}`;
|
||||
const secretKey = hexToUint8(this.config.secretKey);
|
||||
const sigBytes = nacl.sign.detached(decodeUTF8(canonical), secretKey);
|
||||
const signature = uint8ToHex(sigBytes);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.config.meshId,
|
||||
memberId: this.config.memberId,
|
||||
pubkey: this.config.pubkey,
|
||||
displayName: this.config.displayName,
|
||||
sessionId: `connector-tg-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
peerType: "connector",
|
||||
channel: "telegram",
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
);
|
||||
|
||||
this.helloTimer = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, HELLO_ACK_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
ws.on("message", (raw: WebSocket.RawData) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
this.flushOutbound();
|
||||
console.log("[mesh] connected to broker");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleServerMessage(msg);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this.ws = null;
|
||||
const wasConnected = this.connected;
|
||||
this.connected = false;
|
||||
if (!wasConnected) {
|
||||
reject(new Error("ws closed before hello_ack"));
|
||||
}
|
||||
if (!this.closed) this.scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
console.error(`[mesh] ws error: ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a message to the mesh. targetSpec: "*" for broadcast, pubkey for direct. */
|
||||
async send(
|
||||
targetSpec: string,
|
||||
message: string,
|
||||
priority: "now" | "next" | "low" = "next",
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const id = randomId();
|
||||
// Connectors send plaintext broadcasts (base64 encoded) —
|
||||
// direct crypto_box encryption is omitted for simplicity.
|
||||
const nonce = encodeBase64(nacl.randomBytes(24));
|
||||
const ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const dispatch = (): void => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (this.connected) {
|
||||
dispatch();
|
||||
} else {
|
||||
this.outbound.push(dispatch);
|
||||
}
|
||||
|
||||
// Ack timeout
|
||||
setTimeout(() => {
|
||||
resolve({ ok: false, error: "ack timeout" });
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||
if (msg.type === "push") {
|
||||
const push = msg as unknown as InboundPush & { senderDisplayName?: string };
|
||||
|
||||
// Decode plaintext for broadcasts/channel messages
|
||||
if (!push.plaintext && push.ciphertext) {
|
||||
try {
|
||||
push.plaintext = Buffer.from(push.ciphertext, "base64").toString("utf-8");
|
||||
} catch {
|
||||
// leave null
|
||||
}
|
||||
}
|
||||
|
||||
// Cache peer display name if provided
|
||||
if (push.senderDisplayName && push.senderPubkey) {
|
||||
this.peerNames.set(push.senderPubkey, push.senderDisplayName);
|
||||
}
|
||||
|
||||
for (const handler of this.pushHandlers) {
|
||||
try {
|
||||
handler(push);
|
||||
} catch (err) {
|
||||
console.error("[mesh] push handler error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === "peers") {
|
||||
// Cache peer names from peer list responses
|
||||
const peers = (msg as Record<string, unknown>).peers as Array<{ pubkey: string; displayName: string }> | undefined;
|
||||
if (peers) {
|
||||
for (const p of peers) {
|
||||
this.peerNames.set(p.pubkey, p.displayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private flushOutbound(): void {
|
||||
const fns = this.outbound.splice(0);
|
||||
for (const fn of fns) fn();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const delay = BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
|
||||
this.reconnectAttempt++;
|
||||
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect().catch((err) => {
|
||||
console.error(`[mesh] reconnect failed:`, err);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hex helpers (avoid libsodium dependency) ---
|
||||
|
||||
function hexToUint8(hex: string): Uint8Array {
|
||||
const len = hex.length / 2;
|
||||
const arr = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function uint8ToHex(arr: Uint8Array): string {
|
||||
return Array.from(arr)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
148
packages/connector-telegram/src/telegram.ts
Normal file
148
packages/connector-telegram/src/telegram.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Minimal Telegram Bot API client using fetch + long polling.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
const POLL_TIMEOUT_SECS = 30;
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
from?: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
};
|
||||
chat: { id: number; type: string; title?: string };
|
||||
date: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface Update {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
}
|
||||
|
||||
interface GetUpdatesResponse {
|
||||
ok: boolean;
|
||||
result: Update[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SendMessageResponse {
|
||||
ok: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MessageHandler = (msg: TelegramMessage) => void;
|
||||
|
||||
export class TelegramClient {
|
||||
private baseUrl: string;
|
||||
private offset = 0;
|
||||
private running = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private handlers = new Set<MessageHandler>();
|
||||
|
||||
constructor(
|
||||
private botToken: string,
|
||||
private chatId: string,
|
||||
) {
|
||||
this.baseUrl = `https://api.telegram.org/bot${botToken}`;
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler): void {
|
||||
this.handlers.add(handler);
|
||||
}
|
||||
|
||||
/** Send a text message to the configured chat. */
|
||||
async sendMessage(text: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as SendMessageResponse;
|
||||
if (!data.ok) {
|
||||
console.error(`[telegram] sendMessage failed: ${data.description}`);
|
||||
}
|
||||
return data.ok;
|
||||
} catch (err) {
|
||||
console.error(`[telegram] sendMessage error:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start long-polling loop. Non-blocking — runs in background. */
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.pollLoop();
|
||||
}
|
||||
|
||||
/** Stop the polling loop gracefully. */
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
private async pollLoop(): Promise<void> {
|
||||
while (this.running) {
|
||||
try {
|
||||
this.abortController = new AbortController();
|
||||
const url = new URL(`${this.baseUrl}/getUpdates`);
|
||||
url.searchParams.set("offset", String(this.offset));
|
||||
url.searchParams.set("timeout", String(POLL_TIMEOUT_SECS));
|
||||
url.searchParams.set("allowed_updates", JSON.stringify(["message"]));
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal: this.abortController.signal,
|
||||
// Allow enough time for the long-poll plus network overhead
|
||||
});
|
||||
|
||||
const data = (await res.json()) as GetUpdatesResponse;
|
||||
|
||||
if (!data.ok) {
|
||||
console.error(`[telegram] getUpdates failed: ${data.description}`);
|
||||
await sleep(5_000);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const update of data.result) {
|
||||
this.offset = update.update_id + 1;
|
||||
if (update.message) {
|
||||
this.dispatchMessage(update.message);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Expected on stop()
|
||||
break;
|
||||
}
|
||||
console.error(`[telegram] poll error:`, err);
|
||||
await sleep(5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchMessage(msg: TelegramMessage): void {
|
||||
// Only relay messages from the configured chat
|
||||
if (String(msg.chat.id) !== this.chatId) return;
|
||||
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
} catch (err) {
|
||||
console.error(`[telegram] handler error:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
17
packages/connector-telegram/tsconfig.json
Normal file
17
packages/connector-telegram/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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
100
packages/sdk/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# @claudemesh/sdk
|
||||
|
||||
Lightweight TypeScript SDK for connecting any process to a claudemesh mesh. Handles WebSocket connections, ed25519 authentication, crypto_box encryption, and auto-reconnect.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @claudemesh/sdk
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { MeshClient, generateKeyPair } from "@claudemesh/sdk";
|
||||
|
||||
const keys = generateKeyPair();
|
||||
const client = new MeshClient({
|
||||
brokerUrl: "wss://ic.claudemesh.com/ws",
|
||||
meshId: "your-mesh-id",
|
||||
memberId: "your-member-id",
|
||||
pubkey: keys.publicKey,
|
||||
secretKey: keys.secretKey,
|
||||
displayName: "My Bot",
|
||||
peerType: "connector",
|
||||
channel: "custom",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Listen for messages
|
||||
client.on("message", (msg) => {
|
||||
console.log(`From ${msg.senderPubkey}: ${msg.plaintext}`);
|
||||
});
|
||||
|
||||
// Listen for peer events
|
||||
client.on("peer_joined", (peer) => {
|
||||
console.log(`${peer.displayName} joined`);
|
||||
});
|
||||
|
||||
client.on("peer_left", (peer) => {
|
||||
console.log(`${peer.displayName} left`);
|
||||
});
|
||||
|
||||
// Send a message (by display name or pubkey)
|
||||
await client.send("Alice", "Hello from SDK!");
|
||||
|
||||
// Broadcast to all peers
|
||||
await client.broadcast("Hello everyone!");
|
||||
|
||||
// List connected peers
|
||||
const peers = await client.listPeers();
|
||||
|
||||
// Shared state
|
||||
await client.setState("build_status", "passing");
|
||||
const value = await client.getState("build_status");
|
||||
|
||||
// Clean up
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `generateKeyPair()`
|
||||
|
||||
Returns `Promise<{ publicKey: string; secretKey: string }>` -- an ed25519 keypair with hex-encoded keys.
|
||||
|
||||
### `new MeshClient(opts)`
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
|--------|------|----------|-------------|
|
||||
| `brokerUrl` | `string` | yes | WebSocket URL of the broker |
|
||||
| `meshId` | `string` | yes | Mesh to join |
|
||||
| `memberId` | `string` | yes | Your member ID within the mesh |
|
||||
| `pubkey` | `string` | yes | Ed25519 public key (hex) |
|
||||
| `secretKey` | `string` | yes | Ed25519 secret key (hex) |
|
||||
| `displayName` | `string` | no | Name visible to other peers |
|
||||
| `peerType` | `"ai" \| "human" \| "connector"` | no | Defaults to `"connector"` |
|
||||
| `channel` | `string` | no | Channel identifier |
|
||||
| `debug` | `boolean` | no | Log debug info to stderr |
|
||||
|
||||
### Methods
|
||||
|
||||
- `connect(): Promise<void>` -- Open connection and authenticate
|
||||
- `disconnect(): void` -- Close connection
|
||||
- `send(to, message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to peer name, pubkey, `*`, or `@group`
|
||||
- `broadcast(message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to all peers
|
||||
- `listPeers(): Promise<PeerInfo[]>` -- List connected peers
|
||||
- `getState(key): Promise<string | null>` -- Read shared state
|
||||
- `setState(key, value): Promise<void>` -- Write shared state
|
||||
- `setSummary(summary): Promise<void>` -- Set session summary
|
||||
- `setStatus(status): Promise<void>` -- Set status (`idle`, `working`, `dnd`)
|
||||
|
||||
### Events
|
||||
|
||||
- `"message"` -- Inbound message received
|
||||
- `"connected"` -- WebSocket authenticated
|
||||
- `"disconnected"` -- WebSocket closed
|
||||
- `"peer_joined"` -- A peer connected to the mesh
|
||||
- `"peer_left"` -- A peer disconnected
|
||||
- `"state_change"` -- Shared state was updated by a peer
|
||||
19
packages/sdk/package.json
Normal file
19
packages/sdk/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@claudemesh/sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "SDK for connecting any process to a claudemesh mesh",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.5.13",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
564
packages/sdk/src/client.ts
Normal file
564
packages/sdk/src/client.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* MeshClient -- lightweight WebSocket client for connecting any process
|
||||
* to a claudemesh mesh. Handles:
|
||||
* - hello handshake + ack
|
||||
* - send / ack / push message flow
|
||||
* - auto-reconnect with exponential backoff
|
||||
* - crypto_box encryption for direct messages
|
||||
* - EventEmitter interface for messages, connection, and peer events
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import WebSocket from "ws";
|
||||
import {
|
||||
signHello,
|
||||
generateKeyPair,
|
||||
encryptDirect,
|
||||
decryptDirect,
|
||||
isDirectTarget,
|
||||
} from "./crypto.js";
|
||||
import type {
|
||||
MeshClientOptions,
|
||||
PeerInfo,
|
||||
InboundMessage,
|
||||
Priority,
|
||||
ConnStatus,
|
||||
} from "./types.js";
|
||||
|
||||
interface PendingSend {
|
||||
id: string;
|
||||
targetSpec: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void;
|
||||
}
|
||||
|
||||
const MAX_QUEUED = 100;
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
export interface MeshClientEvents {
|
||||
message: [msg: InboundMessage];
|
||||
connected: [];
|
||||
disconnected: [];
|
||||
peer_joined: [peer: PeerInfo];
|
||||
peer_left: [peer: PeerInfo];
|
||||
state_change: [change: { key: string; value: unknown; updatedBy: string }];
|
||||
}
|
||||
|
||||
export class MeshClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private _status: ConnStatus = "closed";
|
||||
private pendingSends = new Map<string, PendingSend>();
|
||||
private outbound: Array<() => void> = [];
|
||||
private closed = false;
|
||||
private reconnectAttempt = 0;
|
||||
private helloTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Session keypair (generated on first connect, reused across reconnects)
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
|
||||
// Request-response resolvers
|
||||
private listPeersResolvers = new Map<
|
||||
string,
|
||||
{ resolve: (peers: PeerInfo[]) => void; timer: NodeJS.Timeout }
|
||||
>();
|
||||
private stateResolvers = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (
|
||||
result: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
} | null,
|
||||
) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(private opts: MeshClientOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Current connection status. */
|
||||
get status(): ConnStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** Session public key hex (null before first connect). */
|
||||
get pubkey(): string | null {
|
||||
return this.sessionPubkey;
|
||||
}
|
||||
|
||||
/** Open the WebSocket, send hello, resolve when hello_ack received. */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) throw new Error("client is closed");
|
||||
this._status = "connecting";
|
||||
const ws = new WebSocket(this.opts.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onOpen = async (): Promise<void> => {
|
||||
this.debug("ws open -> generating session keypair + signing hello");
|
||||
try {
|
||||
if (!this.sessionPubkey) {
|
||||
const sessionKP = await generateKeyPair();
|
||||
this.sessionPubkey = sessionKP.publicKey;
|
||||
this.sessionSecretKey = sessionKP.secretKey;
|
||||
}
|
||||
|
||||
const { timestamp, signature } = await signHello(
|
||||
this.opts.meshId,
|
||||
this.opts.memberId,
|
||||
this.opts.pubkey,
|
||||
this.opts.secretKey,
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.opts.meshId,
|
||||
memberId: this.opts.memberId,
|
||||
pubkey: this.opts.pubkey,
|
||||
sessionPubkey: this.sessionPubkey,
|
||||
displayName: this.opts.displayName,
|
||||
sessionId: `sdk-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
peerType: this.opts.peerType ?? "connector",
|
||||
channel: this.opts.channel ?? "sdk",
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
reject(
|
||||
new Error(
|
||||
`hello sign failed: ${e instanceof Error ? e.message : e}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.helloTimer = setTimeout(() => {
|
||||
this.debug("hello_ack timeout");
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, HELLO_ACK_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const onMessage = (raw: WebSocket.RawData): void => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "hello_ack") {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
this._status = "open";
|
||||
this.reconnectAttempt = 0;
|
||||
this.flushOutbound();
|
||||
this.emit("connected");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.handleServerMessage(msg);
|
||||
};
|
||||
|
||||
const onClose = (): void => {
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
this.helloTimer = null;
|
||||
const wasOpen = this._status === "open" || this._status === "reconnecting";
|
||||
this.ws = null;
|
||||
if (!wasOpen && this._status === "connecting") {
|
||||
reject(new Error("ws closed before hello_ack"));
|
||||
}
|
||||
if (!this.closed) {
|
||||
this.emit("disconnected");
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this._status = "closed";
|
||||
this.emit("disconnected");
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error): void => {
|
||||
this.debug(`ws error: ${err.message}`);
|
||||
};
|
||||
|
||||
ws.on("open", onOpen);
|
||||
ws.on("message", onMessage);
|
||||
ws.on("close", onClose);
|
||||
ws.on("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close the connection. */
|
||||
disconnect(): void {
|
||||
this.closed = true;
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this._status = "closed";
|
||||
}
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
/**
|
||||
* Send a message to a peer. `to` can be:
|
||||
* - A hex pubkey (64 chars) for encrypted direct message
|
||||
* - A display name (resolved via listPeers)
|
||||
* - "*" for broadcast
|
||||
* - "@groupname" for group message
|
||||
*/
|
||||
async send(
|
||||
to: string,
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
// Resolve display name to pubkey for direct encryption
|
||||
let targetSpec = to;
|
||||
if (!isDirectTarget(to) && to !== "*" && !to.startsWith("@") && !to.startsWith("#")) {
|
||||
const peers = await this.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (match) {
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
// If no match found, send as-is and let the broker resolve
|
||||
}
|
||||
|
||||
const id = randomBytes(8).toString("hex");
|
||||
let nonce: string;
|
||||
let ciphertext: string;
|
||||
|
||||
if (isDirectTarget(targetSpec)) {
|
||||
const env = await encryptDirect(
|
||||
message,
|
||||
targetSpec,
|
||||
this.sessionSecretKey ?? this.opts.secretKey,
|
||||
);
|
||||
nonce = env.nonce;
|
||||
ciphertext = env.ciphertext;
|
||||
} else {
|
||||
nonce = randomBytes(24).toString("base64");
|
||||
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (this.pendingSends.size >= MAX_QUEUED) {
|
||||
resolve({ ok: false, error: "outbound queue full" });
|
||||
return;
|
||||
}
|
||||
this.pendingSends.set(id, {
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
resolve,
|
||||
});
|
||||
const dispatch = (): void => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (this._status === "open") dispatch();
|
||||
else {
|
||||
if (this.outbound.length >= MAX_QUEUED) {
|
||||
this.pendingSends.delete(id);
|
||||
resolve({ ok: false, error: "outbound queue full" });
|
||||
return;
|
||||
}
|
||||
this.outbound.push(dispatch);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (this.pendingSends.has(id)) {
|
||||
this.pendingSends.delete(id);
|
||||
resolve({ ok: false, error: "ack timeout" });
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast a message to all peers in the mesh. */
|
||||
async broadcast(
|
||||
message: string,
|
||||
priority: Priority = "next",
|
||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
return this.send("*", message, priority);
|
||||
}
|
||||
|
||||
// --- Peers ---
|
||||
|
||||
/** Request the list of connected peers from the broker. */
|
||||
async listPeers(): Promise<PeerInfo[]> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.listPeersResolvers.set(reqId, {
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
if (this.listPeersResolvers.delete(reqId)) resolve([]);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(JSON.stringify({ type: "list_peers", _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
/** Read a shared state value. */
|
||||
async getState(
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.stateResolvers.set(reqId, {
|
||||
resolve: (result) => resolve(result ? String(result.value) : null),
|
||||
timer: setTimeout(() => {
|
||||
if (this.stateResolvers.delete(reqId)) resolve(null);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a shared state value visible to all peers. */
|
||||
async setState(key: string, value: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_state", key, value }));
|
||||
}
|
||||
|
||||
// --- Summary / Status ---
|
||||
|
||||
/** Update this session's summary visible to other peers. */
|
||||
async setSummary(summary: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
/** Override connection status visible to peers. */
|
||||
async setStatus(status: "idle" | "working" | "dnd"): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "set_status", status }));
|
||||
}
|
||||
|
||||
// --- Internals ---
|
||||
|
||||
private makeReqId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
private flushOutbound(): void {
|
||||
const queued = this.outbound.slice();
|
||||
this.outbound.length = 0;
|
||||
for (const send of queued) send();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this._status = "reconnecting";
|
||||
const delay =
|
||||
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
|
||||
this.reconnectAttempt += 1;
|
||||
this.debug(`reconnect in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (this.closed) return;
|
||||
this.connect().catch((e) => {
|
||||
this.debug(
|
||||
`reconnect failed: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||
const reqId = msg._reqId as string | undefined;
|
||||
|
||||
if (msg.type === "ack") {
|
||||
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
ok: true,
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
});
|
||||
this.pendingSends.delete(pending.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "peers_list") {
|
||||
const peers = (msg.peers as PeerInfo[]) ?? [];
|
||||
this.resolveFromMap(this.listPeersResolvers, reqId, peers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "push") {
|
||||
void this.handlePush(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "state_result") {
|
||||
if (msg.key) {
|
||||
this.resolveFromMap(this.stateResolvers, reqId, {
|
||||
key: String(msg.key),
|
||||
value: msg.value,
|
||||
updatedBy: String(msg.updatedBy ?? ""),
|
||||
updatedAt: String(msg.updatedAt ?? ""),
|
||||
});
|
||||
} else {
|
||||
this.resolveFromMap(this.stateResolvers, reqId, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "state_change") {
|
||||
this.emit("state_change", {
|
||||
key: String(msg.key ?? ""),
|
||||
value: msg.value,
|
||||
updatedBy: String(msg.updatedBy ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
||||
const id = msg.id ? String(msg.id) : null;
|
||||
if (id) {
|
||||
const pending = this.pendingSends.get(id);
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
ok: false,
|
||||
error: `${msg.code}: ${msg.message}`,
|
||||
});
|
||||
this.pendingSends.delete(id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePush(msg: Record<string, unknown>): Promise<void> {
|
||||
const nonce = String(msg.nonce ?? "");
|
||||
const ciphertext = String(msg.ciphertext ?? "");
|
||||
const senderPubkey = String(msg.senderPubkey ?? "");
|
||||
|
||||
const kind: InboundMessage["kind"] = senderPubkey ? "direct" : "unknown";
|
||||
let plaintext: string | null = null;
|
||||
|
||||
// Try crypto_box decryption for direct messages
|
||||
if (senderPubkey && nonce && ciphertext) {
|
||||
plaintext = await decryptDirect(
|
||||
{ nonce, ciphertext },
|
||||
senderPubkey,
|
||||
this.sessionSecretKey ?? this.opts.secretKey,
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast/channel fallback: base64 UTF-8 decode
|
||||
if (plaintext === null && ciphertext && !senderPubkey) {
|
||||
try {
|
||||
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try base64 decode even for direct (handles broadcasts
|
||||
// and key mismatches gracefully)
|
||||
if (plaintext === null && ciphertext) {
|
||||
try {
|
||||
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||
if (
|
||||
/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) &&
|
||||
decoded.length > 0
|
||||
) {
|
||||
plaintext = decoded;
|
||||
}
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
|
||||
const push: InboundMessage = {
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
meshId: String(msg.meshId ?? ""),
|
||||
senderPubkey,
|
||||
priority: (msg.priority as Priority) ?? "next",
|
||||
nonce,
|
||||
ciphertext,
|
||||
createdAt: String(msg.createdAt ?? ""),
|
||||
receivedAt: new Date().toISOString(),
|
||||
plaintext,
|
||||
kind,
|
||||
...(msg.subtype
|
||||
? { subtype: msg.subtype as "reminder" | "system" }
|
||||
: {}),
|
||||
...(msg.event ? { event: String(msg.event) } : {}),
|
||||
...(msg.eventData
|
||||
? { eventData: msg.eventData as Record<string, unknown> }
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.emit("message", push);
|
||||
|
||||
// Emit peer_joined / peer_left convenience events
|
||||
if (push.event === "peer_joined" && push.eventData) {
|
||||
this.emit("peer_joined", push.eventData as unknown as PeerInfo);
|
||||
}
|
||||
if (push.event === "peer_left" && push.eventData) {
|
||||
this.emit("peer_left", push.eventData as unknown as PeerInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveFromMap<T>(
|
||||
map: Map<string, { resolve: (v: T) => void; timer: NodeJS.Timeout }>,
|
||||
reqId: string | undefined,
|
||||
value: T,
|
||||
): boolean {
|
||||
let entry = reqId ? map.get(reqId) : undefined;
|
||||
if (!entry) {
|
||||
// Fallback: oldest pending (FIFO, for brokers that don't echo _reqId)
|
||||
const first = map.entries().next().value as
|
||||
| [string, { resolve: (v: T) => void; timer: NodeJS.Timeout }]
|
||||
| undefined;
|
||||
if (first) {
|
||||
entry = first[1];
|
||||
map.delete(first[0]);
|
||||
}
|
||||
} else {
|
||||
map.delete(reqId!);
|
||||
}
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.resolve(value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private debug(msg: string): void {
|
||||
if (this.opts.debug) console.error(`[claudemesh-sdk] ${msg}`);
|
||||
}
|
||||
}
|
||||
136
packages/sdk/src/crypto.ts
Normal file
136
packages/sdk/src/crypto.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Cryptographic primitives for the claudemesh SDK.
|
||||
*
|
||||
* Uses libsodium-wrappers for ed25519 keypair generation, hello signing,
|
||||
* and crypto_box direct-message encryption. This matches the CLI's crypto
|
||||
* implementation exactly, ensuring wire-level compatibility.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
let ready = false;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
if (!ready) {
|
||||
await sodium.ready;
|
||||
ready = true;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
/** An ed25519 keypair with hex-encoded keys. */
|
||||
export interface Ed25519Keypair {
|
||||
/** 32-byte public key, hex-encoded. */
|
||||
publicKey: string;
|
||||
/** 64-byte secret key (seed || publicKey), hex-encoded. */
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
/** Generate a fresh ed25519 keypair for use as mesh identity. */
|
||||
export async function generateKeyPair(): Promise<Ed25519Keypair> {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_sign_keypair();
|
||||
return {
|
||||
publicKey: s.to_hex(kp.publicKey),
|
||||
secretKey: s.to_hex(kp.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a hello handshake message.
|
||||
*
|
||||
* Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}`
|
||||
* Must match the broker's `canonicalHello()` exactly.
|
||||
*/
|
||||
export async function signHello(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
pubkey: string,
|
||||
secretKeyHex: string,
|
||||
): Promise<{ timestamp: number; signature: string }> {
|
||||
const s = await ensureSodium();
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||
const sig = s.crypto_sign_detached(
|
||||
s.from_string(canonical),
|
||||
s.from_hex(secretKeyHex),
|
||||
);
|
||||
return { timestamp, signature: s.to_hex(sig) };
|
||||
}
|
||||
|
||||
/** Encrypted envelope wire format. */
|
||||
export interface Envelope {
|
||||
nonce: string; // base64
|
||||
ciphertext: string; // base64
|
||||
}
|
||||
|
||||
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** Check whether a targetSpec is a hex pubkey (direct message target). */
|
||||
export function isDirectTarget(targetSpec: string): boolean {
|
||||
return HEX_PUBKEY.test(targetSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext message for a single recipient using crypto_box.
|
||||
*
|
||||
* Ed25519 keys are converted to X25519 on the fly for Diffie-Hellman.
|
||||
*/
|
||||
export async function encryptDirect(
|
||||
message: string,
|
||||
recipientPubkeyHex: string,
|
||||
senderSecretKeyHex: string,
|
||||
): Promise<Envelope> {
|
||||
const s = await ensureSodium();
|
||||
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(
|
||||
s.from_hex(recipientPubkeyHex),
|
||||
);
|
||||
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(
|
||||
s.from_hex(senderSecretKeyHex),
|
||||
);
|
||||
const nonce = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||
const ciphertext = s.crypto_box_easy(
|
||||
s.from_string(message),
|
||||
nonce,
|
||||
recipientPub,
|
||||
senderSec,
|
||||
);
|
||||
return {
|
||||
nonce: s.to_base64(nonce, s.base64_variants.ORIGINAL),
|
||||
ciphertext: s.to_base64(ciphertext, s.base64_variants.ORIGINAL),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an inbound envelope from a known sender using crypto_box_open.
|
||||
* Returns null if decryption fails.
|
||||
*/
|
||||
export async function decryptDirect(
|
||||
envelope: Envelope,
|
||||
senderPubkeyHex: string,
|
||||
recipientSecretKeyHex: string,
|
||||
): Promise<string | null> {
|
||||
const s = await ensureSodium();
|
||||
try {
|
||||
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(
|
||||
s.from_hex(senderPubkeyHex),
|
||||
);
|
||||
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(
|
||||
s.from_hex(recipientSecretKeyHex),
|
||||
);
|
||||
const nonce = s.from_base64(envelope.nonce, s.base64_variants.ORIGINAL);
|
||||
const ciphertext = s.from_base64(
|
||||
envelope.ciphertext,
|
||||
s.base64_variants.ORIGINAL,
|
||||
);
|
||||
const plain = s.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
senderPub,
|
||||
recipientSec,
|
||||
);
|
||||
return s.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
packages/sdk/src/index.ts
Normal file
9
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { MeshClient } from "./client.js";
|
||||
export { generateKeyPair } from "./crypto.js";
|
||||
export type {
|
||||
PeerInfo,
|
||||
InboundMessage,
|
||||
Priority,
|
||||
ConnStatus,
|
||||
MeshClientOptions,
|
||||
} from "./types.js";
|
||||
64
packages/sdk/src/types.ts
Normal file
64
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/** Priority levels for message delivery. */
|
||||
export type Priority = "now" | "next" | "low";
|
||||
|
||||
/** Connection status of the client. */
|
||||
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||
|
||||
/** Information about a connected peer. */
|
||||
export interface PeerInfo {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
cwd?: string;
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
channel?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** An inbound message received from the broker. */
|
||||
export interface InboundMessage {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
/** Decrypted plaintext. null if decryption failed or broadcast. */
|
||||
plaintext: string | null;
|
||||
/** Message kind: "direct" (crypto_box), "broadcast", "channel", or "unknown". */
|
||||
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||
/** Optional semantic tag. */
|
||||
subtype?: "reminder" | "system";
|
||||
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||
event?: string;
|
||||
/** Structured payload for the event. */
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Options for constructing a MeshClient. */
|
||||
export interface MeshClientOptions {
|
||||
/** WebSocket URL of the broker (e.g. "wss://ic.claudemesh.com/ws"). */
|
||||
brokerUrl: string;
|
||||
/** Mesh ID to join. */
|
||||
meshId: string;
|
||||
/** Member ID within the mesh. */
|
||||
memberId: string;
|
||||
/** Ed25519 public key (hex). Used for signing the hello handshake. */
|
||||
pubkey: string;
|
||||
/** Ed25519 secret key (hex). Used for signing and encryption. */
|
||||
secretKey: string;
|
||||
/** Display name visible to other peers. */
|
||||
displayName?: string;
|
||||
/** Peer type: "ai", "human", or "connector". Defaults to "connector". */
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
/** Channel identifier (e.g. "claude-code", "custom"). */
|
||||
channel?: string;
|
||||
/** Enable debug logging to stderr. */
|
||||
debug?: boolean;
|
||||
}
|
||||
13
packages/sdk/tsconfig.json
Normal file
13
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user