Compare commits
155 Commits
v0.5.5
...
dbea96960f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbea96960f | ||
|
|
a022da1998 | ||
|
|
5df2664bae | ||
|
|
816c42feae | ||
|
|
4c0a417b7c | ||
|
|
e6962f1454 | ||
|
|
1d506f3ea5 | ||
|
|
64266a75f7 | ||
|
|
2710f354a9 | ||
|
|
6b55859d38 | ||
|
|
7d31cc6283 | ||
|
|
0403cfeb76 | ||
|
|
d8e6900072 | ||
|
|
ed8dab8bd3 | ||
|
|
dad51870d9 | ||
|
|
a6af0f2154 | ||
|
|
0661e6223a | ||
|
|
05e3c43e29 | ||
|
|
e3fa6e6a5e | ||
|
|
17066b4f6c | ||
|
|
8d1685e64d | ||
|
|
bb28e16c7d | ||
|
|
ac59d2acfe | ||
|
|
0a1af84712 | ||
|
|
18dc29aba1 | ||
|
|
795217093f | ||
|
|
61b0813924 | ||
|
|
c10337ab9f | ||
|
|
126bbfeb2c | ||
|
|
c914f2b7db | ||
|
|
a8b9348b36 | ||
|
|
c3dd4efe82 | ||
|
|
a7d9ecab15 | ||
|
|
d263fe0f26 | ||
|
|
3226493e6d | ||
|
|
4cb5a97512 | ||
|
|
c080bc517f | ||
|
|
471e88b3e6 | ||
|
|
c66e3adf67 | ||
|
|
3f46a6657a | ||
|
|
83ba1aa373 | ||
|
|
7430e4ffe0 | ||
|
|
d72e49b8fd | ||
|
|
3f57944921 | ||
|
|
b31aab8aeb | ||
|
|
5db9842261 | ||
|
|
81e520fdbb | ||
|
|
26c4502277 | ||
|
|
bfc62b9a72 | ||
|
|
f8c6f9ae74 | ||
|
|
3497700fad | ||
|
|
2c156f832e | ||
|
|
4ee810242d | ||
|
|
b6224c4186 | ||
|
|
4c385a16cc | ||
|
|
4ae6a86bf6 | ||
|
|
c327c282e3 | ||
|
|
e645455b22 | ||
|
|
45505a1635 | ||
|
|
17e6361d64 | ||
|
|
528e7e21b1 | ||
|
|
7b875de301 | ||
|
|
8a3c96dc7c | ||
|
|
b0634b829c | ||
|
|
2bd388a5e2 | ||
|
|
71c0767a1b | ||
|
|
6a3f087209 | ||
|
|
873f588057 | ||
|
|
070a3b7422 | ||
|
|
75ca892ea7 | ||
|
|
a90046a8e3 | ||
|
|
02a165dd76 | ||
|
|
52393429f9 | ||
|
|
9474d985ae | ||
|
|
643c808685 | ||
|
|
2c24f667f9 | ||
|
|
b0113913f2 | ||
|
|
e1cafa54b3 | ||
|
|
a4f2e0aa81 | ||
|
|
cbcde4d910 | ||
|
|
495c234159 | ||
|
|
42c1d02f5e | ||
|
|
a33c925216 | ||
|
|
6ab3fbbea3 | ||
|
|
26adbafde2 | ||
|
|
13e8ce07ac | ||
|
|
5398ca6833 | ||
|
|
56b1cc0756 | ||
|
|
fc8a7edc23 | ||
|
|
e09671cdcb | ||
|
|
32fc4a0c98 | ||
|
|
b315b31cc9 | ||
|
|
21cb6efced | ||
|
|
125b576e2c | ||
|
|
3641618391 | ||
|
|
a92cf6b629 | ||
|
|
2c9c8c7b6c | ||
|
|
98fda20ab6 | ||
|
|
025a53a70c | ||
|
|
b55cf269a4 | ||
|
|
504111c50c | ||
|
|
05d9b56f28 | ||
|
|
c8cb1e3ea5 | ||
|
|
86a258301f | ||
|
|
7e102a235b | ||
|
|
5563f90733 | ||
|
|
b3b9972e60 | ||
|
|
fe9285351b | ||
|
|
08e289a5e3 | ||
|
|
7d432b3aaa | ||
|
|
b0dc538119 | ||
|
|
27c9d2a02c | ||
|
|
87e0d0004d | ||
|
|
dba0fb7b33 | ||
|
|
72be651ca8 | ||
|
|
db2bf3ea06 | ||
|
|
e87380775f | ||
|
|
58ba01f20f | ||
|
|
59332dc47d | ||
|
|
f34b8fbc6b | ||
|
|
79525af42e | ||
|
|
69e93d4b8c | ||
|
|
810f372d1c | ||
|
|
453705a4e1 | ||
|
|
5cb4cc4fe7 | ||
|
|
eeac47c360 | ||
|
|
0bb9d71a26 | ||
|
|
3ff7a61e3f | ||
|
|
e76ade64d2 | ||
|
|
59848f0d3e | ||
|
|
d0fa1c028f | ||
|
|
8f925d9a9e | ||
|
|
4ce1034dcd | ||
|
|
e26a36e543 | ||
|
|
60c74d9463 | ||
|
|
6fba9bd4eb | ||
|
|
5bcc1fe323 | ||
|
|
e70f0ed1ff | ||
|
|
5f696f47ea | ||
|
|
ccb9fb2a68 | ||
|
|
898c061089 | ||
|
|
f7a6559429 | ||
|
|
579d0c3d3e | ||
|
|
190f5a958e | ||
|
|
03661e1b68 | ||
|
|
d451fc296e | ||
|
|
3da5d71275 | ||
|
|
cdf335f609 | ||
|
|
0cd16ff358 | ||
|
|
3e9707276d | ||
|
|
82cfee315c | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 |
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
||||
|
||||
# Default locale of the apps, can be overridden separately in each app.
|
||||
DEFAULT_LOCALE="en"
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
|
||||
3
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# secrets
|
||||
.cli_sync_secret
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# claudemesh
|
||||
|
||||
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
||||
|
||||
## Structure
|
||||
|
||||
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`
|
||||
- `apps/cli/` — `claudemesh-cli` npm package (CLI + MCP server)
|
||||
- `apps/web/` — Marketing site + dashboard at claudemesh.com
|
||||
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
|
||||
|
||||
## Key docs
|
||||
|
||||
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
|
||||
- `docs/protocol.md` — Wire protocol reference
|
||||
- `docs/roadmap.md` — Public roadmap (shipped + planned)
|
||||
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
|
||||
|
||||
## Deploy
|
||||
|
||||
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`
|
||||
- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks`
|
||||
- **Web:** Vercel auto-deploy on push to GitHub
|
||||
|
||||
## Dev
|
||||
|
||||
- Monorepo: pnpm workspaces + Turborepo
|
||||
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
|
||||
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
|
||||
- CLI link for local testing: `cd apps/cli && npm link`
|
||||
@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
|
||||
|
||||
EXPOSE 7900
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"grammy": "^1.35.0",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/broker/src/broker-crypto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Broker-side symmetric encryption for persisting resolved env vars.
|
||||
*
|
||||
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
|
||||
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
|
||||
* a random key is generated and logged on first use — operator should
|
||||
* persist it to survive broker restarts.
|
||||
*
|
||||
* This is NOT the same as peer-side E2E crypto (libsodium). This is
|
||||
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { env } from "./env";
|
||||
import { log } from "./logger";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let _key: Buffer | null = null;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (_key) return _key;
|
||||
|
||||
if (env.BROKER_ENCRYPTION_KEY && env.BROKER_ENCRYPTION_KEY.length === 64) {
|
||||
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
|
||||
} else {
|
||||
_key = randomBytes(32);
|
||||
log.warn("BROKER_ENCRYPTION_KEY not set — generated ephemeral key. " +
|
||||
"Set BROKER_ENCRYPTION_KEY=" + _key.toString("hex") + " to persist across restarts.");
|
||||
}
|
||||
return _key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a JSON-serializable value. Returns a base64 string containing
|
||||
* IV + ciphertext + auth tag.
|
||||
*/
|
||||
export function encryptForStorage(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Pack: IV (12) + tag (16) + ciphertext
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encryptForStorage. Returns the plaintext
|
||||
* string, or null if decryption fails (wrong key, tampered).
|
||||
*/
|
||||
export function decryptFromStorage(packed: string): string | null {
|
||||
try {
|
||||
const key = getKey();
|
||||
const buf = Buffer.from(packed, "base64");
|
||||
const iv = buf.subarray(0, IV_LEN);
|
||||
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,15 @@ import {
|
||||
mesh,
|
||||
meshFile,
|
||||
meshFileAccess,
|
||||
meshFileKey,
|
||||
meshContext,
|
||||
meshMember as memberTable,
|
||||
meshMemory,
|
||||
meshState,
|
||||
meshService,
|
||||
meshSkill,
|
||||
meshStream,
|
||||
meshVaultEntry,
|
||||
meshTask,
|
||||
messageQueue,
|
||||
pendingStatus,
|
||||
@@ -395,6 +399,7 @@ export async function listPeersInMesh(
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
connectedAt: Date;
|
||||
}>
|
||||
> {
|
||||
@@ -408,6 +413,7 @@ export async function listPeersInMesh(
|
||||
summary: presence.summary,
|
||||
groups: presence.groups,
|
||||
sessionId: presence.sessionId,
|
||||
cwd: presence.cwd,
|
||||
connectedAt: presence.connectedAt,
|
||||
})
|
||||
.from(presence)
|
||||
@@ -427,6 +433,7 @@ export async function listPeersInMesh(
|
||||
summary: r.summary,
|
||||
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
|
||||
sessionId: r.sessionId,
|
||||
cwd: r.cwd,
|
||||
connectedAt: r.connectedAt,
|
||||
}));
|
||||
}
|
||||
@@ -700,6 +707,182 @@ 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,
|
||||
manifest?: unknown,
|
||||
): 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,
|
||||
manifest: manifest ?? null,
|
||||
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,
|
||||
manifest: manifest ?? null,
|
||||
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;
|
||||
manifest: unknown;
|
||||
createdAt: Date;
|
||||
} | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
instructions: meshSkill.instructions,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
manifest: meshSkill.manifest,
|
||||
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",
|
||||
manifest: r.manifest,
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List skills in a mesh, optionally filtering by keyword across name, description, and tags.
|
||||
*/
|
||||
export async function listSkills(
|
||||
meshId: string,
|
||||
query?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(
|
||||
and(
|
||||
eq(meshSkill.meshId, meshId),
|
||||
or(
|
||||
sql`${meshSkill.name} ILIKE ${pattern}`,
|
||||
sql`${meshSkill.description} ILIKE ${pattern}`,
|
||||
sql`EXISTS (SELECT 1 FROM unnest(${meshSkill.tags}) AS t WHERE t ILIKE ${pattern})`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(eq(meshSkill.meshId, meshId))
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a skill by name in a mesh. Returns true if a row was deleted.
|
||||
*/
|
||||
export async function removeSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.returning({ id: meshSkill.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// --- File sharing ---
|
||||
|
||||
/**
|
||||
@@ -717,6 +900,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember?: string;
|
||||
targetSpec?: string;
|
||||
expiresAt?: Date;
|
||||
encrypted?: boolean;
|
||||
ownerPubkey?: string;
|
||||
}): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshFile)
|
||||
@@ -732,6 +917,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember: args.uploadedByMember ?? null,
|
||||
targetSpec: args.targetSpec ?? null,
|
||||
expiresAt: args.expiresAt ?? null,
|
||||
encrypted: args.encrypted ?? false,
|
||||
ownerPubkey: args.ownerPubkey ?? null,
|
||||
})
|
||||
.returning({ id: meshFile.id });
|
||||
if (!row) throw new Error("failed to insert file row");
|
||||
@@ -755,6 +942,8 @@ export async function getFile(
|
||||
uploadedByName: string | null;
|
||||
targetSpec: string | null;
|
||||
uploadedAt: Date;
|
||||
encrypted: boolean;
|
||||
ownerPubkey: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
@@ -768,6 +957,8 @@ export async function getFile(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
targetSpec: meshFile.targetSpec,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
encrypted: meshFile.encrypted,
|
||||
ownerPubkey: meshFile.ownerPubkey,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(
|
||||
@@ -782,6 +973,8 @@ export async function getFile(
|
||||
return {
|
||||
...row,
|
||||
tags: (row.tags ?? []) as string[],
|
||||
encrypted: row.encrypted,
|
||||
ownerPubkey: row.ownerPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -801,6 +994,7 @@ export async function listFiles(
|
||||
uploadedBy: string;
|
||||
uploadedAt: Date;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>
|
||||
> {
|
||||
const conditions = [
|
||||
@@ -822,6 +1016,7 @@ export async function listFiles(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
persistent: meshFile.persistent,
|
||||
encrypted: meshFile.encrypted,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(and(...conditions))
|
||||
@@ -835,6 +1030,7 @@ export async function listFiles(
|
||||
uploadedBy: r.uploadedByName ?? "unknown",
|
||||
uploadedAt: r.uploadedAt,
|
||||
persistent: r.persistent,
|
||||
encrypted: r.encrypted,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -892,11 +1088,62 @@ export async function deleteFile(
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert encrypted key blobs for a newly uploaded E2E file. */
|
||||
export async function insertFileKeys(
|
||||
fileId: string,
|
||||
keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>,
|
||||
): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await db.insert(meshFileKey).values(
|
||||
keys.map((k) => ({
|
||||
fileId,
|
||||
peerPubkey: k.peerPubkey,
|
||||
sealedKey: k.sealedKey,
|
||||
grantedByPubkey: k.grantedByPubkey ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the sealed key for a specific peer, or null if not authorized. */
|
||||
export async function getFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ sealedKey: meshFileKey.sealedKey })
|
||||
.from(meshFileKey)
|
||||
.where(
|
||||
and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)),
|
||||
);
|
||||
return row?.sealedKey ?? null;
|
||||
}
|
||||
|
||||
/** Grant a peer access to an encrypted file (upsert their key blob). */
|
||||
export async function grantFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
sealedKey: string,
|
||||
grantedByPubkey: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(meshFileKey)
|
||||
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
|
||||
.onConflictDoUpdate({
|
||||
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
|
||||
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Context sharing ---
|
||||
|
||||
/**
|
||||
* Upsert a context snapshot for a peer. Each (meshId, presenceId) pair
|
||||
* has at most one context row — repeated calls update it in place.
|
||||
* Upsert a context snapshot for a peer. When `memberId` is provided the
|
||||
* row is keyed on (meshId, memberId) — a stable identifier that survives
|
||||
* reconnects. This prevents stale rows from accumulating every time a
|
||||
* session reconnects with a fresh ephemeral presenceId.
|
||||
*
|
||||
* Falls back to (meshId, presenceId) lookup when memberId is absent
|
||||
* (e.g. legacy callers or anonymous connections).
|
||||
*/
|
||||
export async function shareContext(
|
||||
meshId: string,
|
||||
@@ -906,24 +1153,27 @@ export async function shareContext(
|
||||
filesRead?: string[],
|
||||
keyFindings?: string[],
|
||||
tags?: string[],
|
||||
memberId?: string,
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
// Try to find existing context for this presence in this mesh.
|
||||
|
||||
// Build the WHERE clause: prefer stable memberId, fall back to presenceId.
|
||||
const lookupWhere = memberId
|
||||
? and(eq(meshContext.meshId, meshId), eq(meshContext.memberId, memberId))
|
||||
: and(eq(meshContext.meshId, meshId), eq(meshContext.presenceId, presenceId));
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: meshContext.id })
|
||||
.from(meshContext)
|
||||
.where(
|
||||
and(
|
||||
eq(meshContext.meshId, meshId),
|
||||
eq(meshContext.presenceId, presenceId),
|
||||
),
|
||||
)
|
||||
.where(lookupWhere)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(meshContext)
|
||||
.set({
|
||||
// Keep presenceId current so it reflects the latest connection.
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
filesRead: filesRead ?? [],
|
||||
@@ -939,6 +1189,7 @@ export async function shareContext(
|
||||
.insert(meshContext)
|
||||
.values({
|
||||
meshId,
|
||||
memberId: memberId ?? null,
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
@@ -1188,16 +1439,22 @@ export async function createStream(
|
||||
name: string,
|
||||
createdByName: string,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
// Atomic upsert: INSERT ... ON CONFLICT DO NOTHING to avoid TOCTOU race
|
||||
// when two callers concurrently attempt to create the same stream.
|
||||
const [inserted] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: meshStream.id });
|
||||
|
||||
if (inserted) return inserted.id;
|
||||
|
||||
// Row already existed — fetch the id.
|
||||
const [existing] = await db
|
||||
.select({ id: meshStream.id })
|
||||
.from(meshStream)
|
||||
.where(and(eq(meshStream.meshId, meshId), eq(meshStream.name, name)));
|
||||
if (existing.length > 0) return existing[0]!.id;
|
||||
const [row] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.returning({ id: meshStream.id });
|
||||
return row!.id;
|
||||
return existing!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,11 +1559,28 @@ export async function drainForMember(
|
||||
);
|
||||
|
||||
// Build group target matching: @all (broadcast alias) + @<groupname>
|
||||
// for each group the peer belongs to.
|
||||
// for each group the peer belongs to, expanded to all ancestor paths.
|
||||
//
|
||||
// Hierarchical routing (downward propagation):
|
||||
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
|
||||
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
|
||||
// This lets leads send to a parent group and reach all sub-teams.
|
||||
//
|
||||
// Resolution happens at drain time (pull model) — no duplicates stored,
|
||||
// no schema changes, fully backward-compatible.
|
||||
const groupTargets = ["@all"];
|
||||
if (memberGroups) {
|
||||
const seen = new Set<string>();
|
||||
for (const g of memberGroups) {
|
||||
groupTargets.push(`@${g}`);
|
||||
const parts = g.split("/");
|
||||
// Add the group itself + every ancestor prefix.
|
||||
for (let depth = parts.length; depth > 0; depth--) {
|
||||
const ancestor = parts.slice(0, depth).join("/");
|
||||
if (!seen.has(ancestor)) {
|
||||
seen.add(ancestor);
|
||||
groupTargets.push(`@${ancestor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupTargetList = sql.raw(
|
||||
@@ -1337,7 +1611,7 @@ export async function drainForMember(
|
||||
AND delivered_at IS NULL
|
||||
AND priority::text IN (${priorityList})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
||||
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
@@ -1532,13 +1806,18 @@ export async function joinMesh(args: {
|
||||
if (!claimed) return { ok: false, error: "invite_exhausted" };
|
||||
|
||||
// 6. Insert the member with the role from the payload.
|
||||
// Apply invite preset overrides (displayName, roleTag, groups, messageMode).
|
||||
const preset = (inv.preset as any) ?? {};
|
||||
const [row] = await db
|
||||
.insert(memberTable)
|
||||
.values({
|
||||
meshId: invitePayload.mesh_id,
|
||||
peerPubkey,
|
||||
displayName,
|
||||
displayName: preset.displayName ?? displayName,
|
||||
role: invitePayload.role,
|
||||
roleTag: preset.roleTag ?? null,
|
||||
defaultGroups: preset.groups ?? [],
|
||||
messageMode: preset.messageMode ?? "push",
|
||||
})
|
||||
.returning({ id: memberTable.id });
|
||||
if (!row) return { ok: false, error: "member_insert_failed" };
|
||||
@@ -1552,12 +1831,24 @@ export async function joinMesh(args: {
|
||||
export async function findMemberByPubkey(
|
||||
meshId: string,
|
||||
pubkey: string,
|
||||
): Promise<{ id: string; displayName: string; role: string } | null> {
|
||||
): Promise<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
roleTag: string | null;
|
||||
defaultGroups: Array<{ name: string; role?: string }>;
|
||||
messageMode: string | null;
|
||||
dashboardUserId: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: memberTable.id,
|
||||
displayName: memberTable.displayName,
|
||||
role: memberTable.role,
|
||||
roleTag: memberTable.roleTag,
|
||||
defaultGroups: memberTable.defaultGroups,
|
||||
messageMode: memberTable.messageMode,
|
||||
dashboardUserId: memberTable.dashboardUserId,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
@@ -1685,3 +1976,91 @@ export async function meshSchema(
|
||||
}
|
||||
return [...tables.entries()].map(([name, columns]) => ({ name, columns }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function vaultSet(meshId: string, memberId: string, key: string, ciphertext: string, nonce: string, sealedKey: string, entryType: "env" | "file", mountPath?: string, description?: string): Promise<string> {
|
||||
const existing = await db.select({ id: meshVaultEntry.id }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(meshVaultEntry).set({ ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null, updatedAt: new Date() }).where(eq(meshVaultEntry.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
const [row] = await db.insert(meshVaultEntry).values({ meshId, memberId, key, ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null }).returning({ id: meshVaultEntry.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
export async function vaultList(meshId: string, memberId: string) {
|
||||
return db.select({ key: meshVaultEntry.key, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath, description: meshVaultEntry.description, updatedAt: meshVaultEntry.updatedAt }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId)));
|
||||
}
|
||||
|
||||
export async function vaultDelete(meshId: string, memberId: string, key: string): Promise<boolean> {
|
||||
const deleted = await db.delete(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).returning({ id: meshVaultEntry.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
export async function vaultGetEntries(meshId: string, memberId: string, keys: string[]) {
|
||||
if (keys.length === 0) return [];
|
||||
return db.select({ key: meshVaultEntry.key, ciphertext: meshVaultEntry.ciphertext, nonce: meshVaultEntry.nonce, sealedKey: meshVaultEntry.sealedKey, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), inArray(meshVaultEntry.key, keys)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service catalog operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function upsertService(meshId: string, name: string, data: { type: "mcp" | "skill"; sourceType: string; description: string; sourceFileId?: string; sourceGitUrl?: string; sourceGitBranch?: string; sourceGitSha?: string; instructions?: string; toolsSchema?: unknown; manifest?: unknown; runtime?: string; status?: string; config?: unknown; scope?: unknown; deployedBy?: string; deployedByName?: string }): Promise<string> {
|
||||
// Whitelist allowed fields — prevent mass-assignment of id, meshId, createdAt, etc.
|
||||
const fields: Record<string, unknown> = {
|
||||
type: data.type,
|
||||
sourceType: data.sourceType,
|
||||
description: data.description,
|
||||
...(data.sourceFileId !== undefined && { sourceFileId: data.sourceFileId }),
|
||||
...(data.sourceGitUrl !== undefined && { sourceGitUrl: data.sourceGitUrl }),
|
||||
...(data.sourceGitBranch !== undefined && { sourceGitBranch: data.sourceGitBranch }),
|
||||
...(data.sourceGitSha !== undefined && { sourceGitSha: data.sourceGitSha }),
|
||||
...(data.instructions !== undefined && { instructions: data.instructions }),
|
||||
...(data.toolsSchema !== undefined && { toolsSchema: data.toolsSchema }),
|
||||
...(data.manifest !== undefined && { manifest: data.manifest }),
|
||||
...(data.runtime !== undefined && { runtime: data.runtime }),
|
||||
...(data.status !== undefined && { status: data.status }),
|
||||
...(data.config !== undefined && { config: data.config }),
|
||||
...(data.scope !== undefined && { scope: data.scope }),
|
||||
...(data.deployedBy !== undefined && { deployedBy: data.deployedBy }),
|
||||
...(data.deployedByName !== undefined && { deployedByName: data.deployedByName }),
|
||||
};
|
||||
|
||||
const existing = await db.select({ id: meshService.id }).from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(meshService).set({ ...fields, updatedAt: new Date() } as any).where(eq(meshService.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
const [row] = await db.insert(meshService).values({ meshId, name, ...fields } as any).returning({ id: meshService.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
export async function updateServiceStatus(meshId: string, name: string, status: string, extra?: { toolsSchema?: unknown; restartCount?: number; lastHealth?: Date }) {
|
||||
await db.update(meshService).set({ status, ...(extra ?? {}), updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
|
||||
}
|
||||
|
||||
export async function updateServiceScope(meshId: string, name: string, scope: unknown) {
|
||||
await db.update(meshService).set({ scope, updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
|
||||
}
|
||||
|
||||
export async function getService(meshId: string, name: string) {
|
||||
const rows = await db.select().from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listDbMeshServices(meshId: string) {
|
||||
return db.select().from(meshService).where(eq(meshService.meshId, meshId));
|
||||
}
|
||||
|
||||
export async function deleteService(meshId: string, name: string): Promise<boolean> {
|
||||
const deleted = await db.delete(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).returning({ id: meshService.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
export async function getRunningServices(meshId: string) {
|
||||
return db.select().from(meshService).where(and(eq(meshService.meshId, meshId), inArray(meshService.status, ["running", "failed", "crashed", "restarting"])));
|
||||
}
|
||||
|
||||
133
apps/broker/src/cli-sync.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* POST /cli-sync handler.
|
||||
*
|
||||
* Accepts a sync JWT from the dashboard, creates or finds member rows
|
||||
* for each mesh in the token, and returns mesh details + member IDs.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
|
||||
|
||||
// Import schema tables
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
export interface CliSyncRequest {
|
||||
sync_token: string;
|
||||
peer_pubkey: string; // ed25519 hex (64 chars)
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface CliSyncResponse {
|
||||
ok: true;
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CliSyncError {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function handleCliSync(
|
||||
body: CliSyncRequest,
|
||||
): Promise<CliSyncResponse | CliSyncError> {
|
||||
// 1. Validate inputs
|
||||
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
|
||||
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
|
||||
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
|
||||
}
|
||||
|
||||
// 2. Verify JWT
|
||||
const tokenResult = await verifySyncToken(body.sync_token);
|
||||
if (!tokenResult.ok) {
|
||||
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
|
||||
}
|
||||
const payload = tokenResult.payload;
|
||||
|
||||
// 3. For each mesh in the token, create or find a member row
|
||||
const resultMeshes: CliSyncResponse["meshes"] = [];
|
||||
|
||||
for (const tokenMesh of payload.meshes) {
|
||||
// Verify mesh exists and is not archived
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, slug: meshTable.slug })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) {
|
||||
// Skip meshes that don't exist (could have been deleted)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this pubkey is already a member of this mesh
|
||||
const [existing] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.meshId, tokenMesh.id),
|
||||
eq(memberTable.peerPubkey, body.peer_pubkey),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
let memberId: string;
|
||||
let role: "admin" | "member";
|
||||
|
||||
if (existing) {
|
||||
// Already a member — update dashboard link + display name
|
||||
memberId = existing.id;
|
||||
role = existing.role;
|
||||
await db
|
||||
.update(memberTable)
|
||||
.set({
|
||||
dashboardUserId: payload.sub,
|
||||
displayName: body.display_name,
|
||||
})
|
||||
.where(eq(memberTable.id, existing.id));
|
||||
} else {
|
||||
// Create new member row
|
||||
memberId = generateId();
|
||||
role = tokenMesh.role;
|
||||
await db.insert(memberTable).values({
|
||||
id: memberId,
|
||||
meshId: tokenMesh.id,
|
||||
peerPubkey: body.peer_pubkey,
|
||||
displayName: body.display_name,
|
||||
role: tokenMesh.role,
|
||||
dashboardUserId: payload.sub,
|
||||
});
|
||||
}
|
||||
|
||||
resultMeshes.push({
|
||||
mesh_id: tokenMesh.id,
|
||||
slug: m.slug,
|
||||
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
member_id: memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
if (resultMeshes.length === 0) {
|
||||
return { ok: false, error: "no valid meshes found in sync token" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
account_id: payload.sub,
|
||||
meshes: resultMeshes,
|
||||
};
|
||||
}
|
||||
@@ -23,11 +23,17 @@ const envSchema = z.object({
|
||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.coerce.boolean().default(false),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
|
||||
146
apps/broker/src/jwt.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* JWT verification for CLI sync tokens.
|
||||
*
|
||||
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
|
||||
* shared secret between dashboard and broker via env var.
|
||||
*
|
||||
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
|
||||
*/
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface SyncTokenPayload {
|
||||
sub: string; // dashboard user ID
|
||||
email: string;
|
||||
meshes: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
jti: string; // unique token ID for replay prevention
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- JTI dedup ---
|
||||
|
||||
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
|
||||
|
||||
// Sweep expired JTIs every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [jti, exp] of usedJtis) {
|
||||
if (exp < now) usedJtis.delete(jti);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
// --- Verification ---
|
||||
|
||||
/**
|
||||
* Verify and decode a sync token JWT.
|
||||
* Returns the decoded payload on success, or an error string on failure.
|
||||
*/
|
||||
export async function verifySyncToken(
|
||||
token: string,
|
||||
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
|
||||
// Get shared secret from env
|
||||
const secret = env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode JWT manually (HS256)
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, error: "malformed JWT" };
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Verify signature (HS256)
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
|
||||
if (!valid) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
|
||||
// Decode header — must be HS256
|
||||
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
|
||||
if (header.alg !== "HS256") {
|
||||
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
||||
) as SyncTokenPayload;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
return { ok: false, error: "token expired" };
|
||||
}
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (payload.iat && payload.iat > now + 30) {
|
||||
return { ok: false, error: "token issued in the future" };
|
||||
}
|
||||
|
||||
// JTI dedup
|
||||
if (!payload.jti) {
|
||||
return { ok: false, error: "missing jti" };
|
||||
}
|
||||
if (usedJtis.has(payload.jti)) {
|
||||
return { ok: false, error: "token already used" };
|
||||
}
|
||||
// Mark as used with expiry time
|
||||
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
|
||||
|
||||
// Basic validation
|
||||
if (!payload.sub || !payload.email) {
|
||||
return { ok: false, error: "missing sub or email" };
|
||||
}
|
||||
if (!Array.isArray(payload.meshes)) {
|
||||
return { ok: false, error: "missing meshes array" };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64UrlDecode(input: string): Uint8Array {
|
||||
// Add padding
|
||||
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (base64.length % 4) base64 += "=";
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
284
apps/broker/src/member-api.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Member profile REST API handlers.
|
||||
*
|
||||
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
||||
* GET /mesh/:meshId/members — list all members with online status
|
||||
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
||||
*
|
||||
* These are standalone handler functions. Route wiring happens in index.ts.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
presence as presenceTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MemberProfileUpdate {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
|
||||
export interface MemberPermissionUpdate {
|
||||
permission?: "admin" | "member"; // only admins can change this
|
||||
}
|
||||
|
||||
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
||||
|
||||
interface SelfEditablePolicy {
|
||||
displayName: boolean;
|
||||
roleTag: boolean;
|
||||
groups: boolean;
|
||||
messageMode: boolean;
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
/**
|
||||
* Update a member's profile fields.
|
||||
*
|
||||
* Authorization:
|
||||
* - If caller is the target member: check mesh.selfEditable for each field
|
||||
* - If caller is a mesh admin: allow all fields
|
||||
* - permission field: admin-only always
|
||||
*
|
||||
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
||||
*/
|
||||
export async function updateMemberProfile(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
callerMemberId: string, // from auth header or WS connection
|
||||
updates: MemberUpdateRequest,
|
||||
): Promise<
|
||||
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// 1. Load mesh for selfEditable policy
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// 2. Load caller's member row to check permission
|
||||
const [caller] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
||||
|
||||
const isAdmin = caller.role === "admin";
|
||||
const isSelf = callerMemberId === memberId;
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "not authorized — only admins or self can edit",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check self-edit permissions for non-admin self-edits
|
||||
const policy: SelfEditablePolicy =
|
||||
(m.selfEditable as SelfEditablePolicy) ?? {
|
||||
displayName: true,
|
||||
roleTag: true,
|
||||
groups: true,
|
||||
messageMode: true,
|
||||
};
|
||||
|
||||
const rejected: string[] = [];
|
||||
if (!isAdmin && isSelf) {
|
||||
if (updates.displayName !== undefined && !policy.displayName)
|
||||
rejected.push("displayName");
|
||||
if (updates.roleTag !== undefined && !policy.roleTag)
|
||||
rejected.push("roleTag");
|
||||
if (updates.groups !== undefined && !policy.groups)
|
||||
rejected.push("groups");
|
||||
if (updates.messageMode !== undefined && !policy.messageMode)
|
||||
rejected.push("messageMode");
|
||||
if (updates.permission !== undefined) rejected.push("permission");
|
||||
}
|
||||
|
||||
if (rejected.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `admin-managed fields: ${rejected.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Build update set
|
||||
const set: Record<string, unknown> = {};
|
||||
const changes: MemberProfileUpdate = {};
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
set.displayName = updates.displayName;
|
||||
changes.displayName = updates.displayName;
|
||||
}
|
||||
if (updates.roleTag !== undefined) {
|
||||
set.roleTag = updates.roleTag;
|
||||
changes.roleTag = updates.roleTag;
|
||||
}
|
||||
if (updates.groups !== undefined) {
|
||||
set.defaultGroups = updates.groups;
|
||||
changes.groups = updates.groups;
|
||||
}
|
||||
if (updates.messageMode !== undefined) {
|
||||
set.messageMode = updates.messageMode;
|
||||
changes.messageMode = updates.messageMode;
|
||||
}
|
||||
if (updates.permission !== undefined && isAdmin) {
|
||||
set.role = updates.permission;
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no fields to update" };
|
||||
}
|
||||
|
||||
// 5. Update member row
|
||||
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
||||
|
||||
// 6. Read back the updated member
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(eq(memberTable.id, memberId));
|
||||
|
||||
if (!updated) return { ok: false, error: "member not found after update" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
member: {
|
||||
id: updated.id,
|
||||
displayName: updated.displayName,
|
||||
roleTag: updated.roleTag,
|
||||
groups: updated.defaultGroups,
|
||||
messageMode: updated.messageMode,
|
||||
permission: updated.role,
|
||||
dashboardUserId: updated.dashboardUserId,
|
||||
joinedAt: updated.joinedAt,
|
||||
lastSeenAt: updated.lastSeenAt,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all members of a mesh with online status.
|
||||
*/
|
||||
export async function listMeshMembers(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
| { ok: true; members: Array<Record<string, unknown>> }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// Verify mesh exists
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// Get all non-revoked members
|
||||
const members = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
||||
);
|
||||
|
||||
// Early return for empty member list (avoids invalid SQL IN clause)
|
||||
if (members.length === 0) {
|
||||
return { ok: true, members: [] };
|
||||
}
|
||||
|
||||
// Get active presences for online status
|
||||
const activePresences = await db
|
||||
.select({
|
||||
memberId: presenceTable.memberId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(presenceTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(presenceTable.disconnectedAt),
|
||||
sql`${presenceTable.memberId} IN (${sql.join(
|
||||
members.map((m) => sql`${m.id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(presenceTable.memberId);
|
||||
|
||||
const onlineMap = new Map(
|
||||
activePresences.map((p) => [p.memberId, p.count]),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
roleTag: member.roleTag,
|
||||
groups: member.defaultGroups,
|
||||
messageMode: member.messageMode,
|
||||
permission: member.role,
|
||||
dashboardUserId: member.dashboardUserId,
|
||||
joinedAt: member.joinedAt?.toISOString(),
|
||||
lastSeenAt: member.lastSeenAt?.toISOString(),
|
||||
online: onlineMap.has(member.id),
|
||||
sessionCount: onlineMap.get(member.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mesh settings (currently: selfEditable policy).
|
||||
* Admin-only.
|
||||
*/
|
||||
export async function updateMeshSettings(
|
||||
meshId: string,
|
||||
callerMemberId: string,
|
||||
settings: { selfEditable?: SelfEditablePolicy },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Check caller is admin
|
||||
const [caller] = await db
|
||||
.select({ role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller || caller.role !== "admin") {
|
||||
return { ok: false, error: "admin access required" };
|
||||
}
|
||||
|
||||
const set: Record<string, unknown> = {};
|
||||
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no settings to update" };
|
||||
}
|
||||
|
||||
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
788
apps/broker/src/service-manager.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Service Manager — lifecycle management for mesh-deployed MCP servers.
|
||||
*
|
||||
* Each deployed MCP server runs as a child process with its own stdio pipe.
|
||||
* The manager spawns, monitors, restarts, and routes tool calls to them.
|
||||
*
|
||||
* In production: child processes run inside a Docker container (one per mesh).
|
||||
* In dev: child processes run directly on the broker host.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** MCP tool definition returned by tools/list. */
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-service deploy-time configuration. */
|
||||
export interface ServiceConfig {
|
||||
env?: Record<string, string>;
|
||||
memory_mb?: number;
|
||||
cpus?: number;
|
||||
network_allow?: string[];
|
||||
runtime?: "node" | "python" | "bun";
|
||||
}
|
||||
|
||||
/** Observable lifecycle states. */
|
||||
export type ServiceStatus =
|
||||
| "building"
|
||||
| "installing"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "failed"
|
||||
| "crashed"
|
||||
| "restarting";
|
||||
|
||||
/** Internal bookkeeping for a spawned service. */
|
||||
interface ManagedService {
|
||||
name: string;
|
||||
meshId: string;
|
||||
process: ChildProcess | null;
|
||||
tools: ToolDef[];
|
||||
status: ServiceStatus;
|
||||
config: ServiceConfig;
|
||||
sourcePath: string;
|
||||
runtime: "node" | "python" | "bun";
|
||||
restartCount: number;
|
||||
maxRestarts: number;
|
||||
healthFailures: number;
|
||||
logBuffer: string[]; // ring buffer, max LOG_BUFFER_SIZE
|
||||
pendingCalls: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (result: { result?: unknown; error?: string }) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>;
|
||||
pid?: number;
|
||||
startedAt?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG_BUFFER_SIZE = 1000;
|
||||
const HEALTH_INTERVAL_MS = 30_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
const MAX_HEALTH_FAILURES = 3;
|
||||
const DEFAULT_MAX_RESTARTS = 5;
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const SERVICES_BASE_DIR =
|
||||
process.env.CLAUDEMESH_SERVICES_DIR ?? "/var/claudemesh/services";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const services = new Map<string, ManagedService>(); // keyed by "meshId:serviceName"
|
||||
let healthTimer: NodeJS.Timer | null = null;
|
||||
|
||||
function serviceKey(meshId: string, name: string): string {
|
||||
return `${meshId}:${name}`;
|
||||
}
|
||||
|
||||
/** Validate service name: alphanumeric, hyphens, underscores only. No path traversal. */
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
export function validateServiceName(name: string): string | null {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
return "service name must be 1-64 chars, alphanumeric/hyphens/underscores, starting with alphanumeric";
|
||||
}
|
||||
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
||||
return "service name must not contain path separators";
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the runtime for a service based on its source directory contents.
|
||||
*
|
||||
* Priority: bun (lockfile/config) > node (package.json) > python
|
||||
* (pyproject.toml / requirements.txt). Falls back to node.
|
||||
*/
|
||||
export function detectRuntime(sourcePath: string): "node" | "python" | "bun" {
|
||||
if (
|
||||
existsSync(join(sourcePath, "bun.lockb")) ||
|
||||
existsSync(join(sourcePath, "bunfig.toml"))
|
||||
) {
|
||||
return "bun";
|
||||
}
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
return "node";
|
||||
}
|
||||
if (
|
||||
existsSync(join(sourcePath, "pyproject.toml")) ||
|
||||
existsSync(join(sourcePath, "requirements.txt"))
|
||||
) {
|
||||
return "python";
|
||||
}
|
||||
return "node"; // default
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectEntry(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): { command: string; args: string[] } {
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
for (const entry of [
|
||||
"server.py",
|
||||
"src/server.py",
|
||||
"main.py",
|
||||
"src/main.py",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: "python", args: [entry] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) {
|
||||
return { command: "python", args: ["-m", "server"] };
|
||||
}
|
||||
return { command: "python", args: ["server.py"] };
|
||||
}
|
||||
|
||||
// Node / Bun
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(sourcePath, "package.json"), "utf-8"),
|
||||
);
|
||||
if (pkg.main) return { command: cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin =
|
||||
typeof pkg.bin === "string"
|
||||
? pkg.bin
|
||||
: (Object.values(pkg.bin)[0] as string);
|
||||
if (bin) return { command: cmd, args: [bin] };
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Common entry points
|
||||
for (const entry of [
|
||||
"dist/index.js",
|
||||
"src/index.js",
|
||||
"src/index.ts",
|
||||
"index.js",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: cmd, args: [entry] };
|
||||
}
|
||||
}
|
||||
|
||||
return { command: cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Install dependencies for a service. Resolves on success, rejects with
|
||||
* the tail of stderr on failure.
|
||||
*/
|
||||
export async function installDeps(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "."];
|
||||
}
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun";
|
||||
args = ["install"];
|
||||
} else {
|
||||
cmd = "npm";
|
||||
args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: sourcePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`${cmd} install failed (exit ${code}): ${stderr.slice(-500)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log ring buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function appendLog(svc: ManagedService, line: string): void {
|
||||
svc.logBuffer.push(`${new Date().toISOString()} ${line}`);
|
||||
if (svc.logBuffer.length > LOG_BUFFER_SIZE) {
|
||||
svc.logBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP JSON-RPC helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let callIdCounter = 0;
|
||||
|
||||
function sendMcpRequest(
|
||||
svc: ManagedService,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (!svc.process || !svc.process.stdin?.writable) {
|
||||
resolve({ error: "service not running" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `call_${++callIdCounter}`;
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({ error: `tool call timed out after ${CALL_TIMEOUT_MS}ms` });
|
||||
}, CALL_TIMEOUT_MS);
|
||||
|
||||
svc.pendingCalls.set(id, { resolve, timer });
|
||||
|
||||
try {
|
||||
svc.process.stdin!.write(JSON.stringify(request) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({
|
||||
error: `write failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize MCP server (handshake + tool discovery)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initializeMcp(svc: ManagedService): Promise<ToolDef[]> {
|
||||
// MCP initialize handshake
|
||||
const initResult = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
|
||||
if (initResult.error) {
|
||||
throw new Error(`MCP initialize failed: ${initResult.error}`);
|
||||
}
|
||||
|
||||
// Send initialized notification (no response expected)
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch tool list
|
||||
const toolsResult = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (toolsResult.error) {
|
||||
throw new Error(`tools/list failed: ${toolsResult.error}`);
|
||||
}
|
||||
|
||||
const result = toolsResult.result as { tools?: ToolDef[] } | undefined;
|
||||
return result?.tools ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn an MCP server child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnService(svc: ManagedService): void {
|
||||
const { command, args } = detectEntry(svc.sourcePath, svc.runtime);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...(svc.config.env ?? {}),
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.startedAt = new Date();
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
// Read MCP JSON-RPC responses from stdout
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
rl.on("line", (line) => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pendingCalls.has(String(msg.id))) {
|
||||
const pending = svc.pendingCalls.get(String(msg.id))!;
|
||||
clearTimeout(pending.timer);
|
||||
svc.pendingCalls.delete(String(msg.id));
|
||||
if (msg.error) {
|
||||
pending.resolve({
|
||||
error: msg.error.message ?? JSON.stringify(msg.error),
|
||||
});
|
||||
} else {
|
||||
pending.resolve({ result: msg.result });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as log output
|
||||
appendLog(svc, `[stdout] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture stderr as logs
|
||||
const stderrRl = createInterface({ input: child.stderr! });
|
||||
stderrRl.on("line", (line) => {
|
||||
appendLog(svc, `[stderr] ${line}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
log.warn("service exited", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
code,
|
||||
signal,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
|
||||
// Reject all pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service crashed" });
|
||||
}
|
||||
svc.pendingCalls.clear();
|
||||
svc.process = null;
|
||||
svc.pid = undefined;
|
||||
|
||||
// Auto-restart if under limit
|
||||
if (svc.status === "running" && svc.restartCount < svc.maxRestarts) {
|
||||
svc.restartCount++;
|
||||
svc.status = "restarting";
|
||||
log.info("auto-restarting service", {
|
||||
service: svc.name,
|
||||
attempt: svc.restartCount,
|
||||
});
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restartCount); // backoff
|
||||
} else if (svc.status === "running") {
|
||||
svc.status = "crashed";
|
||||
log.error("service max restarts exceeded", {
|
||||
service: svc.name,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error("service spawn error", {
|
||||
service: svc.name,
|
||||
error: err.message,
|
||||
});
|
||||
svc.status = "failed";
|
||||
});
|
||||
|
||||
log.info("service spawned", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
pid: child.pid,
|
||||
command,
|
||||
args,
|
||||
runtime: svc.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deploy (or redeploy) an MCP server.
|
||||
*
|
||||
* Installs dependencies, spawns the child process, runs the MCP
|
||||
* initialize handshake, and returns the discovered tool list.
|
||||
*/
|
||||
export async function deploy(opts: {
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}): Promise<{ tools: ToolDef[]; status: ServiceStatus }> {
|
||||
const key = serviceKey(opts.meshId, opts.name);
|
||||
|
||||
// Kill existing if redeploying
|
||||
const existing = services.get(key);
|
||||
if (existing?.process) {
|
||||
existing.process.kill("SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const runtime = opts.config.runtime ?? detectRuntime(opts.sourcePath);
|
||||
|
||||
const svc: ManagedService = {
|
||||
name: opts.name,
|
||||
meshId: opts.meshId,
|
||||
process: null,
|
||||
tools: [],
|
||||
status: "installing",
|
||||
config: {
|
||||
...opts.config,
|
||||
env: { ...(opts.config.env ?? {}), ...(opts.resolvedEnv ?? {}) },
|
||||
},
|
||||
sourcePath: opts.sourcePath,
|
||||
runtime,
|
||||
restartCount: 0,
|
||||
maxRestarts: DEFAULT_MAX_RESTARTS,
|
||||
healthFailures: 0,
|
||||
logBuffer: [],
|
||||
pendingCalls: new Map(),
|
||||
};
|
||||
|
||||
services.set(key, svc);
|
||||
|
||||
// Install dependencies
|
||||
try {
|
||||
await installDeps(opts.sourcePath, runtime);
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`Install failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Spawn and initialize
|
||||
spawnService(svc);
|
||||
|
||||
// Wait a moment for the process to start
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Get tool list via MCP initialize handshake
|
||||
try {
|
||||
svc.tools = await initializeMcp(svc);
|
||||
log.info("service deployed", {
|
||||
service: opts.name,
|
||||
mesh_id: opts.meshId,
|
||||
tools: svc.tools.length,
|
||||
runtime,
|
||||
});
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`MCP init failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { tools: svc.tools, status: svc.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Undeploy a running service. Sends SIGTERM, waits for graceful exit
|
||||
* (up to 10 s), then SIGKILL. All pending tool calls are rejected.
|
||||
*/
|
||||
export async function undeploy(meshId: string, name: string): Promise<void> {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return;
|
||||
|
||||
svc.status = "stopped";
|
||||
if (svc.process) {
|
||||
svc.process.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
svc.process?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 10_000);
|
||||
svc.process?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reject pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service undeployed" });
|
||||
}
|
||||
|
||||
services.delete(key);
|
||||
log.info("service undeployed", { service: name, mesh_id: meshId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a tool call to the named service. Returns the MCP response
|
||||
* payload or an error string.
|
||||
*/
|
||||
export async function callTool(
|
||||
meshId: string,
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
const key = serviceKey(meshId, serverName);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return { error: `service "${serverName}" not found` };
|
||||
if (svc.status !== "running")
|
||||
return { error: `service "${serverName}" is ${svc.status}` };
|
||||
if (!svc.process)
|
||||
return { error: `service "${serverName}" has no running process` };
|
||||
|
||||
return sendMcpRequest(svc, "tools/call", { name: toolName, arguments: args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last N log lines for a service (from its ring buffer).
|
||||
*/
|
||||
export function getLogs(meshId: string, name: string, lines = 50): string[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return [];
|
||||
return svc.logBuffer.slice(-Math.min(lines, LOG_BUFFER_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current status, PID, restart count, tool list, and uptime
|
||||
* for a single service. Returns null if the service doesn't exist.
|
||||
*/
|
||||
export function getStatus(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): {
|
||||
status: ServiceStatus;
|
||||
pid?: number;
|
||||
restartCount: number;
|
||||
tools: ToolDef[];
|
||||
startedAt?: string;
|
||||
} | null {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return null;
|
||||
return {
|
||||
status: svc.status,
|
||||
pid: svc.pid,
|
||||
restartCount: svc.restartCount,
|
||||
tools: svc.tools,
|
||||
startedAt: svc.startedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the tool definitions for a service, or an empty array if the
|
||||
* service doesn't exist.
|
||||
*/
|
||||
export function getTools(meshId: string, name: string): ToolDef[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
return svc?.tools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all services belonging to a mesh with summary info.
|
||||
*/
|
||||
export function listServices(
|
||||
meshId: string,
|
||||
): Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> {
|
||||
const result: Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> = [];
|
||||
for (const [key, svc] of services) {
|
||||
if (!key.startsWith(`${meshId}:`)) continue;
|
||||
result.push({
|
||||
name: svc.name,
|
||||
status: svc.status,
|
||||
toolCount: svc.tools.length,
|
||||
runtime: svc.runtime,
|
||||
restartCount: svc.restartCount,
|
||||
pid: svc.pid,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function healthCheckAll(): Promise<void> {
|
||||
for (const [, svc] of services) {
|
||||
if (svc.status !== "running" || !svc.process) continue;
|
||||
|
||||
const result = await sendMcpRequest(svc, "ping", {});
|
||||
if (result.error) {
|
||||
svc.healthFailures++;
|
||||
log.warn("health check failed", {
|
||||
service: svc.name,
|
||||
failures: svc.healthFailures,
|
||||
error: result.error,
|
||||
});
|
||||
if (svc.healthFailures >= MAX_HEALTH_FAILURES) {
|
||||
log.error("health check threshold exceeded, restarting", {
|
||||
service: svc.name,
|
||||
});
|
||||
svc.process.kill("SIGTERM");
|
||||
// exit handler will trigger auto-restart
|
||||
}
|
||||
} else {
|
||||
svc.healthFailures = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the periodic health check loop (30 s interval). No-op if already running. */
|
||||
export function startHealthChecks(): void {
|
||||
if (healthTimer) return;
|
||||
healthTimer = setInterval(healthCheckAll, HEALTH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the periodic health check loop. */
|
||||
export function stopHealthChecks(): void {
|
||||
if (healthTimer) {
|
||||
clearInterval(healthTimer);
|
||||
healthTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore all services on broker boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Re-deploy every persisted service record. Called once at broker startup
|
||||
* to bring services back after a restart. Failures are logged but don't
|
||||
* prevent other services from restoring.
|
||||
*/
|
||||
export async function restoreAll(
|
||||
getServiceRecords: () => Promise<
|
||||
Array<{
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const records = await getServiceRecords();
|
||||
log.info("restoring services", { count: records.length });
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
await deploy({
|
||||
meshId: record.meshId,
|
||||
name: record.name,
|
||||
sourcePath: record.sourcePath,
|
||||
config: record.config,
|
||||
resolvedEnv: record.resolvedEnv,
|
||||
});
|
||||
log.info("service restored", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
});
|
||||
} catch (e) {
|
||||
log.error("service restore failed", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startHealthChecks();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gracefully shut down all running services. Stops health checks, sends
|
||||
* SIGTERM to every child, waits for exit, then clears the registry.
|
||||
*/
|
||||
export async function shutdownAll(): Promise<void> {
|
||||
stopHealthChecks();
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [, svc] of services) {
|
||||
if (svc.process) {
|
||||
svc.status = "stopped";
|
||||
promises.push(undeploy(svc.meshId, svc.name));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
services.clear();
|
||||
}
|
||||
1711
apps/broker/src/telegram-bridge.ts
Normal file
148
apps/broker/src/telegram-token.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* JWT utilities for Telegram bridge connections.
|
||||
*
|
||||
* When a user connects their Telegram chat to a mesh, the broker generates
|
||||
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
|
||||
* this token to establish the connection.
|
||||
*
|
||||
* Pure-crypto implementation — no external JWT library.
|
||||
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
|
||||
*
|
||||
* IMPORTANT: The JWT payload contains the member's secretKey.
|
||||
* Never log the token or its decoded payload.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TelegramConnectPayload {
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string; // ed25519 secret key — sensitive
|
||||
createdBy: string; // Dashboard userId or CLI memberId
|
||||
}
|
||||
|
||||
interface JwtClaims extends TelegramConnectPayload {
|
||||
iss: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64url(data: string): string {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
return Buffer.from(str, "base64url").toString("utf-8");
|
||||
}
|
||||
|
||||
function sign(input: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(input).digest("base64url");
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
/**
|
||||
* Create a signed JWT containing Telegram connect credentials.
|
||||
* Expires in 15 minutes.
|
||||
*/
|
||||
export function generateTelegramConnectToken(
|
||||
payload: TelegramConnectPayload,
|
||||
secret: string,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const claims: JwtClaims = {
|
||||
...payload,
|
||||
iss: "claudemesh-broker",
|
||||
sub: "telegram-connect",
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
};
|
||||
|
||||
const encodedPayload = base64url(JSON.stringify(claims));
|
||||
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
|
||||
const signature = sign(signingInput, secret);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode a Telegram connect JWT.
|
||||
* Returns the payload on success, or null on any failure
|
||||
* (bad signature, expired, wrong subject).
|
||||
*/
|
||||
export function validateTelegramConnectToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): TelegramConnectPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSignature = sign(signingInput, secret);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
const a = Buffer.from(signatureB64);
|
||||
const b = Buffer.from(expectedSignature);
|
||||
if (a.length !== b.length) return null;
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
if (!timingSafeEqual(a, b)) return null;
|
||||
|
||||
// Verify header algorithm
|
||||
const header = JSON.parse(base64urlDecode(headerB64));
|
||||
if (header.alg !== "HS256") return null;
|
||||
|
||||
// Decode and validate claims
|
||||
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
|
||||
|
||||
// Check subject
|
||||
if (claims.sub !== "telegram-connect") return null;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp < now) return null;
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (claims.iat > now + 30) return null;
|
||||
|
||||
// Extract payload fields (strip JWT claims)
|
||||
const {
|
||||
meshId,
|
||||
meshSlug,
|
||||
memberId,
|
||||
pubkey,
|
||||
secretKey,
|
||||
createdBy,
|
||||
} = claims;
|
||||
|
||||
// Basic presence check
|
||||
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Telegram deep link that passes the JWT as start parameter.
|
||||
* Format: https://t.me/{botUsername}?start={token}
|
||||
*/
|
||||
export function generateDeepLink(token: string, botUsername: string): string {
|
||||
return `https://t.me/${botUsername}?start=${token}`;
|
||||
}
|
||||
@@ -57,6 +57,14 @@ export interface WSHelloMessage {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
/** OS hostname — used to detect same-machine peers for direct file access. */
|
||||
hostname?: string;
|
||||
/** Peer type: ai session, human user, or external connector. */
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
/** Channel the peer connected from (e.g. "claude-code", "telegram", "slack", "web"). */
|
||||
channel?: string;
|
||||
/** AI model identifier (e.g. "opus-4", "sonnet-4"). */
|
||||
model?: string;
|
||||
/** Initial groups to join on connect. */
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||
@@ -86,6 +94,13 @@ export interface WSPushMessage {
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
/** Optional semantic tag — "reminder" when delivered by the scheduler,
|
||||
* "system" for broker-originated topology events (peer join/leave). */
|
||||
subtype?: "reminder" | "system";
|
||||
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||
event?: string;
|
||||
/** Structured payload for the event. */
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Client → broker: manual status override (dnd, forced idle). */
|
||||
@@ -105,6 +120,36 @@ export interface WSSetSummaryMessage {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
|
||||
/** Client → broker: toggle visibility in the mesh. */
|
||||
export interface WSSetVisibleMessage {
|
||||
type: "set_visible";
|
||||
visible: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: set public profile metadata. */
|
||||
export interface WSSetProfileMessage {
|
||||
type: "set_profile";
|
||||
avatar?: string; // emoji or URL
|
||||
title?: string; // short role label
|
||||
bio?: string; // one-liner
|
||||
capabilities?: string[]; // what I can help with
|
||||
_reqId?: string;
|
||||
}
|
||||
/** Client → broker: self-report resource usage stats. */
|
||||
export interface WSSetStatsMessage {
|
||||
type: "set_stats";
|
||||
stats: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number; // seconds since session start
|
||||
errors?: number;
|
||||
};
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: join a group with optional role. */
|
||||
export interface WSJoinGroupMessage {
|
||||
type: "join_group";
|
||||
@@ -161,6 +206,7 @@ export interface WSAckMessage {
|
||||
id: string; // echoes client-side correlation id
|
||||
messageId: string;
|
||||
queued: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: hello handshake acknowledgement. */
|
||||
@@ -168,6 +214,17 @@ export interface WSHelloAckMessage {
|
||||
type: "hello_ack";
|
||||
presenceId: string;
|
||||
memberDisplayName: string;
|
||||
/** True when the broker restored persisted state from a previous session. */
|
||||
restored?: boolean;
|
||||
/** Last summary set before disconnect (only when restored). */
|
||||
lastSummary?: string;
|
||||
/** ISO timestamp of last disconnect (only when restored). */
|
||||
lastSeenAt?: string;
|
||||
/** Restored groups from previous session (only when restored and hello had no groups). */
|
||||
restoredGroups?: Array<{ name: string; role?: string }>;
|
||||
/** Restored cumulative stats (only when restored). */
|
||||
restoredStats?: { messagesIn: number; messagesOut: number; toolCalls: number; errors: number };
|
||||
services?: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }>;
|
||||
}
|
||||
|
||||
/** Broker → client: list of connected peers in the same mesh. */
|
||||
@@ -181,7 +238,27 @@ export interface WSPeersListMessage {
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
cwd?: string;
|
||||
hostname?: string;
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
channel?: string;
|
||||
model?: string;
|
||||
stats?: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number;
|
||||
errors?: number;
|
||||
};
|
||||
visible?: boolean;
|
||||
profile?: {
|
||||
avatar?: string;
|
||||
title?: string;
|
||||
bio?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: a state key was changed by another peer. */
|
||||
@@ -199,6 +276,7 @@ export interface WSStateResultMessage {
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_state. */
|
||||
@@ -210,12 +288,14 @@ export interface WSStateListMessage {
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for a remember. */
|
||||
export interface WSMemoryStoredMessage {
|
||||
type: "memory_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to recall. */
|
||||
@@ -228,6 +308,7 @@ export interface WSMemoryResultsMessage {
|
||||
rememberedBy: string;
|
||||
rememberedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Vector storage messages ---
|
||||
@@ -295,6 +376,13 @@ export interface WSMeshSchemaMessage {
|
||||
|
||||
// --- Vector/Graph response messages ---
|
||||
|
||||
/** Broker → client: confirmation that a vector point was stored. */
|
||||
export interface WSVectorStoredMessage {
|
||||
type: "vector_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: vector search results. */
|
||||
export interface WSVectorResultsMessage {
|
||||
type: "vector_results";
|
||||
@@ -304,18 +392,21 @@ export interface WSVectorResultsMessage {
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of vector collections. */
|
||||
export interface WSCollectionListMessage {
|
||||
type: "collection_list";
|
||||
collections: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: graph query results. */
|
||||
export interface WSGraphResultMessage {
|
||||
type: "graph_result";
|
||||
records: Array<Record<string, unknown>>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh SQL query results. */
|
||||
@@ -324,6 +415,7 @@ export interface WSMeshQueryResultMessage {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
rowCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh schema introspection results. */
|
||||
@@ -333,6 +425,7 @@ export interface WSMeshSchemaResultMessage {
|
||||
name: string;
|
||||
columns: Array<{ name: string; type: string; nullable: boolean }>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get full mesh overview. */
|
||||
@@ -355,6 +448,7 @@ export interface WSMeshInfoResultMessage {
|
||||
collections: string[];
|
||||
yourName: string;
|
||||
yourGroups: Array<{ name: string; role?: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: check delivery status of a message. */
|
||||
@@ -375,6 +469,7 @@ export interface WSMessageStatusResultMessage {
|
||||
pubkey: string;
|
||||
status: "delivered" | "held" | "disconnected";
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- File sharing messages ---
|
||||
@@ -404,12 +499,23 @@ export interface WSDeleteFileMessage {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/** Client → broker: grant a peer access to an encrypted file. */
|
||||
export interface WSGrantFileAccessMessage {
|
||||
type: "grant_file_access";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
sealedKey: string;
|
||||
}
|
||||
|
||||
/** Broker → client: presigned URL for downloading a file. */
|
||||
export interface WSFileUrlMessage {
|
||||
type: "file_url";
|
||||
fileId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
encrypted?: boolean;
|
||||
sealedKey?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of files in the mesh. */
|
||||
@@ -423,7 +529,17 @@ export interface WSFileListMessage {
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for grant_file_access. */
|
||||
export interface WSGrantFileAccessOkMessage {
|
||||
type: "grant_file_access_ok";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: access log for a file. */
|
||||
@@ -434,6 +550,7 @@ export interface WSFileStatusResultMessage {
|
||||
peerName: string;
|
||||
accessedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Context sharing messages ---
|
||||
@@ -475,6 +592,7 @@ export interface WSContextResultsMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_contexts. */
|
||||
@@ -486,6 +604,7 @@ export interface WSContextListMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Task messages ---
|
||||
@@ -523,6 +642,7 @@ export interface WSListTasksMessage {
|
||||
export interface WSTaskCreatedMessage {
|
||||
type: "task_created";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_tasks, claim_task, complete_task. */
|
||||
@@ -539,6 +659,7 @@ export interface WSTaskListMessage {
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Stream messages ---
|
||||
@@ -578,6 +699,7 @@ export interface WSStreamCreatedMessage {
|
||||
type: "stream_created";
|
||||
id: string;
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: real-time data pushed from a stream. */
|
||||
@@ -588,6 +710,13 @@ export interface WSStreamDataMessage {
|
||||
publishedBy: string;
|
||||
}
|
||||
|
||||
/** Broker → client: confirmation that a stream subscription was registered. */
|
||||
export interface WSSubscribedMessage {
|
||||
type: "subscribed";
|
||||
stream: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_streams. */
|
||||
export interface WSStreamListMessage {
|
||||
type: "stream_list";
|
||||
@@ -598,6 +727,202 @@ export interface WSStreamListMessage {
|
||||
createdAt: string;
|
||||
subscriberCount: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- MCP proxy messages ---
|
||||
|
||||
/** Client → broker: register an MCP server with the mesh. */
|
||||
export interface WSMcpRegisterMessage {
|
||||
type: "mcp_register";
|
||||
serverName: string;
|
||||
description: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
|
||||
persistent?: boolean;
|
||||
_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 }>;
|
||||
online: boolean;
|
||||
offlineSince?: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of an MCP tool call. */
|
||||
export interface WSMcpCallResultMessage {
|
||||
type: "mcp_call_result";
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: forwarded MCP tool call to execute locally. */
|
||||
export interface WSMcpCallForwardMessage {
|
||||
type: "mcp_call_forward";
|
||||
callId: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
callerName: string;
|
||||
}
|
||||
|
||||
// --- Webhook CRUD messages ---
|
||||
|
||||
/** Client → broker: create an inbound webhook. */
|
||||
export interface WSCreateWebhookMessage {
|
||||
type: "create_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list webhooks for the mesh. */
|
||||
export interface WSListWebhooksMessage {
|
||||
type: "list_webhooks";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deactivate a webhook. */
|
||||
export interface WSDeleteWebhookMessage {
|
||||
type: "delete_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for create_webhook. */
|
||||
export interface WSWebhookAckMessage {
|
||||
type: "webhook_ack";
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of webhooks for the mesh. */
|
||||
export interface WSWebhookListMessage {
|
||||
type: "webhook_list";
|
||||
webhooks: Array<{ name: string; url: string; active: boolean; createdAt: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Peer file sharing (relay) messages ---
|
||||
|
||||
/** Client → broker: request a file from a peer's local filesystem. */
|
||||
export interface WSPeerFileRequestMessage {
|
||||
type: "peer_file_request";
|
||||
targetPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded file request from another peer. */
|
||||
export interface WSPeerFileRequestForwardMessage {
|
||||
type: "peer_file_request_forward";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: response with file content (or error). */
|
||||
export interface WSPeerFileResponseMessage {
|
||||
type: "peer_file_response";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
content?: string; // base64 encoded
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded file content from target peer. */
|
||||
export interface WSPeerFileResponseForwardMessage {
|
||||
type: "peer_file_response_forward";
|
||||
filePath: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: request a directory listing from a peer. */
|
||||
export interface WSPeerDirRequestMessage {
|
||||
type: "peer_dir_request";
|
||||
targetPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded directory listing request. */
|
||||
export interface WSPeerDirRequestForwardMessage {
|
||||
type: "peer_dir_request_forward";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: directory listing response. */
|
||||
export interface WSPeerDirResponseMessage {
|
||||
type: "peer_dir_response";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded directory listing from target peer. */
|
||||
export interface WSPeerDirResponseForwardMessage {
|
||||
type: "peer_dir_response_forward";
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: structured error. */
|
||||
@@ -606,14 +931,200 @@ export interface WSErrorMessage {
|
||||
code: string;
|
||||
message: string;
|
||||
id?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Audit log messages ---
|
||||
|
||||
/** Client → broker: query paginated audit entries for a mesh. */
|
||||
export interface WSAuditQueryMessage {
|
||||
type: "audit_query";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
eventType?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: verify the hash chain for the mesh audit log. */
|
||||
export interface WSAuditVerifyMessage {
|
||||
type: "audit_verify";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: paginated audit log entries. */
|
||||
export interface WSAuditResultMessage {
|
||||
type: "audit_result";
|
||||
entries: Array<{
|
||||
id: number;
|
||||
eventType: string;
|
||||
actor: string;
|
||||
payload: Record<string, unknown>;
|
||||
hash: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of hash chain verification. */
|
||||
export interface WSAuditVerifyResultMessage {
|
||||
type: "audit_verify_result";
|
||||
valid: boolean;
|
||||
entries: number;
|
||||
brokenAt?: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Simulation clock messages ---
|
||||
|
||||
/** Client → broker: set the simulation clock speed. */
|
||||
export interface WSSetClockMessage {
|
||||
type: "set_clock";
|
||||
speed: number; // multiplier: 1, 2, 5, 10, 50, 100
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: pause the simulation clock. */
|
||||
export interface WSPauseClockMessage {
|
||||
type: "pause_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: resume a paused simulation clock. */
|
||||
export interface WSResumeClockMessage {
|
||||
type: "resume_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get current clock status. */
|
||||
export interface WSGetClockMessage {
|
||||
type: "get_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: current simulation clock status. */
|
||||
export interface WSClockStatusMessage {
|
||||
type: "clock_status";
|
||||
speed: number;
|
||||
paused: boolean;
|
||||
tick: number;
|
||||
simTime: string; // ISO timestamp
|
||||
startedAt: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
|
||||
/** Client → broker: schedule a message for future delivery. */
|
||||
export interface WSScheduleMessage {
|
||||
type: "schedule";
|
||||
to: string;
|
||||
message: string;
|
||||
/** Unix timestamp (ms) when to deliver. Ignored for cron schedules. */
|
||||
deliverAt: number;
|
||||
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
|
||||
subtype?: "reminder";
|
||||
/** Standard 5-field cron expression for recurring delivery. */
|
||||
cron?: string;
|
||||
/** Whether this is a recurring schedule. Implied true when `cron` is set. */
|
||||
recurring?: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list pending scheduled messages for this member. */
|
||||
export interface WSListScheduledMessage {
|
||||
type: "list_scheduled";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: cancel a scheduled message by id. */
|
||||
export interface WSCancelScheduledMessage {
|
||||
type: "cancel_scheduled";
|
||||
scheduledId: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for schedule, carries the assigned id. */
|
||||
export interface WSScheduledAckMessage {
|
||||
type: "scheduled_ack";
|
||||
scheduledId: string;
|
||||
deliverAt: number;
|
||||
/** Present for cron schedules — echoes the expression. */
|
||||
cron?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of pending scheduled messages. */
|
||||
export interface WSScheduledListMessage {
|
||||
type: "scheduled_list";
|
||||
messages: Array<{
|
||||
id: string;
|
||||
to: string;
|
||||
message: string;
|
||||
deliverAt: number;
|
||||
createdAt: number;
|
||||
/** Present for cron/recurring entries. */
|
||||
cron?: string;
|
||||
/** Number of times the cron entry has fired so far. */
|
||||
firedCount?: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: cancel confirmation. */
|
||||
export interface WSCancelScheduledAckMessage {
|
||||
type: "cancel_scheduled_ack";
|
||||
scheduledId: string;
|
||||
ok: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deploy an MCP server from zip or git. */
|
||||
export interface WSMcpDeployMessage { type: "mcp_deploy"; server_name: string; source: { type: "zip"; file_id: string } | { type: "git"; url: string; branch?: string; auth?: string }; config?: { env?: Record<string, string>; memory_mb?: number; cpus?: number; network_allow?: string[]; runtime?: "node" | "python" | "bun" }; scope?: "peer" | "mesh" | { peers: string[] } | { group: string } | { groups: string[] } | { role: string }; _reqId?: string; }
|
||||
/** Client → broker: stop and remove a managed MCP server. */
|
||||
export interface WSMcpUndeployMessage { type: "mcp_undeploy"; server_name: string; _reqId?: string; }
|
||||
/** Client → broker: pull + rebuild + restart a git-sourced MCP. */
|
||||
export interface WSMcpUpdateMessage { type: "mcp_update"; server_name: string; _reqId?: string; }
|
||||
/** Client → broker: get logs from a managed MCP. */
|
||||
export interface WSMcpLogsMessage { type: "mcp_logs"; server_name: string; lines?: number; _reqId?: string; }
|
||||
/** Client → broker: get or set visibility scope. */
|
||||
export interface WSMcpScopeMessage { type: "mcp_scope"; server_name: string; scope?: "peer" | "mesh" | { peers: string[] } | { group: string } | { groups: string[] } | { role: string }; _reqId?: string; }
|
||||
/** Client → broker: inspect tool schemas for a deployed service. */
|
||||
export interface WSMcpSchemaMessage { type: "mcp_schema"; server_name: string; tool_name?: string; _reqId?: string; }
|
||||
/** Client → broker: list all deployed services. */
|
||||
export interface WSMcpCatalogMessage { type: "mcp_catalog"; _reqId?: string; }
|
||||
/** Client → broker: deploy a skill bundle from zip or git. */
|
||||
export interface WSSkillDeployMessage { type: "skill_deploy"; source: { type: "zip"; file_id: string } | { type: "git"; url: string; branch?: string; auth?: string }; _reqId?: string; }
|
||||
/** Client → broker: store encrypted credential. */
|
||||
export interface WSVaultSetMessage { type: "vault_set"; key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; _reqId?: string; }
|
||||
/** Client → broker: list vault entries. */
|
||||
export interface WSVaultListMessage { type: "vault_list"; _reqId?: string; }
|
||||
/** Client → broker: delete vault entry. */
|
||||
export interface WSVaultDeleteMessage { type: "vault_delete"; key: string; _reqId?: string; }
|
||||
/** Client → broker: fetch encrypted vault entries for local decryption. */
|
||||
export interface WSVaultGetMessage { type: "vault_get"; keys: string[]; _reqId?: string; }
|
||||
|
||||
/** Client → broker: start watching a URL for changes. */
|
||||
export interface WSWatchMessage { type: "watch"; url: string; mode?: "hash" | "json" | "status"; extract?: string; interval?: number; notify_on?: string; headers?: Record<string, string>; label?: string; _reqId?: string; }
|
||||
/** Client → broker: stop watching. */
|
||||
export interface WSUnwatchMessage { type: "unwatch"; watchId: string; _reqId?: string; }
|
||||
/** Client → broker: list active watches. */
|
||||
export interface WSWatchListMessage { type: "watch_list"; _reqId?: string; }
|
||||
/** Broker → client: watch created acknowledgement. */
|
||||
export interface WSWatchAckMessage { type: "watch_ack"; watchId: string; url: string; mode: string; interval: number; _reqId?: string; }
|
||||
/** Broker → client: watch list response. */
|
||||
export interface WSWatchListResultMessage { type: "watch_list_result"; watches: Array<{ id: string; url: string; mode: string; label?: string; interval: number; lastHash?: string; lastValue?: string; lastCheck?: string; createdAt: string }>; _reqId?: string; }
|
||||
/** Broker → client: URL change detected. */
|
||||
export interface WSWatchTriggeredMessage { type: "watch_triggered"; watchId: string; url: string; label?: string; mode: string; oldValue: string; newValue: string; timestamp: string; }
|
||||
|
||||
export type WSClientMessage =
|
||||
| WSHelloMessage
|
||||
| WSSendMessage
|
||||
| WSSetStatusMessage
|
||||
| WSListPeersMessage
|
||||
| WSSetSummaryMessage
|
||||
| WSSetVisibleMessage
|
||||
| WSSetProfileMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage
|
||||
| WSSetStateMessage
|
||||
@@ -627,6 +1138,7 @@ export type WSClientMessage =
|
||||
| WSListFilesMessage
|
||||
| WSFileStatusMessage
|
||||
| WSDeleteFileMessage
|
||||
| WSGrantFileAccessMessage
|
||||
| WSShareContextMessage
|
||||
| WSGetContextMessage
|
||||
| WSListContextsMessage
|
||||
@@ -648,7 +1160,135 @@ export type WSClientMessage =
|
||||
| WSSubscribeMessage
|
||||
| WSUnsubscribeMessage
|
||||
| WSListStreamsMessage
|
||||
| WSMeshInfoMessage;
|
||||
| WSMeshInfoMessage
|
||||
| WSSetClockMessage
|
||||
| WSPauseClockMessage
|
||||
| WSResumeClockMessage
|
||||
| WSGetClockMessage
|
||||
| WSScheduleMessage
|
||||
| WSListScheduledMessage
|
||||
| WSCancelScheduledMessage
|
||||
| WSMcpRegisterMessage
|
||||
| WSMcpUnregisterMessage
|
||||
| WSMcpListMessage
|
||||
| WSMcpCallMessage
|
||||
| WSMcpCallResponseMessage
|
||||
| WSShareSkillMessage
|
||||
| WSGetSkillMessage
|
||||
| WSListSkillsMessage
|
||||
| WSRemoveSkillMessage
|
||||
| WSSetStatsMessage
|
||||
| WSCreateWebhookMessage
|
||||
| WSListWebhooksMessage
|
||||
| WSDeleteWebhookMessage
|
||||
| WSPeerFileRequestMessage
|
||||
| WSPeerFileResponseMessage
|
||||
| WSPeerDirRequestMessage
|
||||
| WSPeerDirResponseMessage
|
||||
| WSAuditQueryMessage
|
||||
| WSAuditVerifyMessage
|
||||
| WSMcpDeployMessage
|
||||
| WSMcpUndeployMessage
|
||||
| WSMcpUpdateMessage
|
||||
| WSMcpLogsMessage
|
||||
| WSMcpScopeMessage
|
||||
| WSMcpSchemaMessage
|
||||
| WSMcpCatalogMessage
|
||||
| WSSkillDeployMessage
|
||||
| WSVaultSetMessage
|
||||
| WSVaultListMessage
|
||||
| WSVaultDeleteMessage
|
||||
| WSVaultGetMessage
|
||||
| WSWatchMessage
|
||||
| WSUnwatchMessage
|
||||
| WSWatchListMessage;
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
/** Broker → client: deployment progress/result. */
|
||||
export interface WSMcpDeployStatusMessage { type: "mcp_deploy_status"; server_name: string; status: "building" | "installing" | "running" | "failed"; tools?: Array<{ name: string; description: string; inputSchema: object }>; error?: string; _reqId?: string; }
|
||||
/** Broker → client: service log output. */
|
||||
export interface WSMcpLogsResultMessage { type: "mcp_logs_result"; server_name: string; lines: string[]; _reqId?: string; }
|
||||
/** Broker → client: tool schema introspection result. */
|
||||
export interface WSMcpSchemaResultMessage { type: "mcp_schema_result"; server_name: string; tools: Array<{ name: string; description: string; inputSchema: object }>; _reqId?: string; }
|
||||
/** Broker → client: full service catalog. */
|
||||
export interface WSMcpCatalogResultMessage { type: "mcp_catalog_result"; services: Array<{ name: string; type: "mcp" | "skill"; description: string; status: string; tool_count: number; deployed_by: string; scope: { type: string; [key: string]: unknown }; source_type: string; runtime?: string; created_at: string }>; _reqId?: string; }
|
||||
/** Broker → client: scope query/set result. */
|
||||
export interface WSMcpScopeResultMessage { type: "mcp_scope_result"; server_name: string; scope: { type: string; [key: string]: unknown }; deployed_by: string; _reqId?: string; }
|
||||
/** Broker → client: skill deploy acknowledgement. */
|
||||
export interface WSSkillDeployAckMessage { type: "skill_deploy_ack"; name: string; files: string[]; _reqId?: string; }
|
||||
/** Broker → client: vault operation acknowledgement. */
|
||||
export interface WSVaultAckMessage { type: "vault_ack"; key: string; action: "stored" | "deleted" | "not_found"; _reqId?: string; }
|
||||
/** Broker → client: vault entry listing. */
|
||||
export interface WSVaultListResultMessage { type: "vault_list_result"; entries: Array<{ key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; updated_at: string }>; _reqId?: string; }
|
||||
/** Broker → client: encrypted vault entries for local decryption. */
|
||||
export interface WSVaultGetResultMessage { type: "vault_get_result"; entries: Array<{ key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: string; mount_path?: string }>; _reqId?: string; }
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
@@ -664,11 +1304,13 @@ export type WSServerMessage =
|
||||
| WSFileUrlMessage
|
||||
| WSFileListMessage
|
||||
| WSFileStatusResultMessage
|
||||
| WSGrantFileAccessOkMessage
|
||||
| WSContextSharedMessage
|
||||
| WSContextResultsMessage
|
||||
| WSContextListMessage
|
||||
| WSTaskCreatedMessage
|
||||
| WSTaskListMessage
|
||||
| WSVectorStoredMessage
|
||||
| WSVectorResultsMessage
|
||||
| WSCollectionListMessage
|
||||
| WSGraphResultMessage
|
||||
@@ -676,6 +1318,38 @@ export type WSServerMessage =
|
||||
| WSMeshSchemaResultMessage
|
||||
| WSStreamCreatedMessage
|
||||
| WSStreamDataMessage
|
||||
| WSSubscribedMessage
|
||||
| WSStreamListMessage
|
||||
| WSMeshInfoResultMessage
|
||||
| WSScheduledAckMessage
|
||||
| WSScheduledListMessage
|
||||
| WSCancelScheduledAckMessage
|
||||
| WSMcpRegisterAckMessage
|
||||
| WSMcpListResultMessage
|
||||
| WSMcpCallResultMessage
|
||||
| WSMcpCallForwardMessage
|
||||
| WSClockStatusMessage
|
||||
| WSSkillAckMessage
|
||||
| WSSkillDataMessage
|
||||
| WSSkillListMessage
|
||||
| WSWebhookAckMessage
|
||||
| WSWebhookListMessage
|
||||
| WSPeerFileRequestForwardMessage
|
||||
| WSPeerFileResponseForwardMessage
|
||||
| WSPeerDirRequestForwardMessage
|
||||
| WSPeerDirResponseForwardMessage
|
||||
| WSAuditResultMessage
|
||||
| WSAuditVerifyResultMessage
|
||||
| WSMcpDeployStatusMessage
|
||||
| WSMcpLogsResultMessage
|
||||
| WSMcpSchemaResultMessage
|
||||
| WSMcpCatalogResultMessage
|
||||
| WSMcpScopeResultMessage
|
||||
| WSSkillDeployAckMessage
|
||||
| WSVaultAckMessage
|
||||
| WSVaultListResultMessage
|
||||
| WSVaultGetResultMessage
|
||||
| WSWatchAckMessage
|
||||
| WSWatchListResultMessage
|
||||
| WSWatchTriggeredMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
97
apps/broker/src/webhooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Inbound webhook handler.
|
||||
*
|
||||
* External services POST JSON to `/hook/:meshId/:secret`. The broker
|
||||
* verifies the secret against the mesh.webhook table, then pushes the
|
||||
* payload to all connected peers in that mesh as a "webhook" push.
|
||||
*/
|
||||
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshWebhook } from "@turbostarter/db/schema/mesh";
|
||||
import type { WSPushMessage } from "./types";
|
||||
import { log } from "./logger";
|
||||
|
||||
export interface WebhookResult {
|
||||
status: number;
|
||||
body: { ok: boolean; delivered?: number; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a webhook by meshId + secret, verify it's active, then return
|
||||
* the webhook name for push routing. Returns null if not found/inactive.
|
||||
*/
|
||||
async function findActiveWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
): Promise<{ id: string; name: string; meshId: string } | null> {
|
||||
const rows = await db
|
||||
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
|
||||
.from(meshWebhook)
|
||||
.where(
|
||||
and(
|
||||
eq(meshWebhook.meshId, meshId),
|
||||
eq(meshWebhook.secret, secret),
|
||||
eq(meshWebhook.active, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook HTTP request.
|
||||
*
|
||||
* @param meshId - mesh ID from the URL path
|
||||
* @param secret - webhook secret from the URL path
|
||||
* @param body - parsed JSON body from the request
|
||||
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
|
||||
* Returns the number of peers the message was delivered to.
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
body: unknown,
|
||||
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
|
||||
): Promise<WebhookResult> {
|
||||
try {
|
||||
const webhook = await findActiveWebhook(meshId, secret);
|
||||
if (!webhook) {
|
||||
log.warn("webhook auth failed", { mesh_id: meshId });
|
||||
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
|
||||
if (body === null || body === undefined || typeof body !== "object") {
|
||||
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
|
||||
}
|
||||
|
||||
const pushMsg: WSPushMessage = {
|
||||
type: "push",
|
||||
subtype: "webhook" as any,
|
||||
event: webhook.name,
|
||||
eventData: body as Record<string, unknown>,
|
||||
messageId: crypto.randomUUID(),
|
||||
meshId: webhook.meshId,
|
||||
senderPubkey: `webhook:${webhook.name}`,
|
||||
priority: "next",
|
||||
nonce: "",
|
||||
ciphertext: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
|
||||
|
||||
log.info("webhook delivered", {
|
||||
webhook_name: webhook.name,
|
||||
mesh_id: webhook.meshId,
|
||||
delivered,
|
||||
});
|
||||
|
||||
return { status: 200, body: { ok: true, delivered } };
|
||||
} catch (e) {
|
||||
log.error("webhook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return { status: 500, body: { ok: false, error: "internal error" } };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.5.5",
|
||||
"version": "0.9.2",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"citty": "0.2.2",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0",
|
||||
"zod": "4.1.13"
|
||||
|
||||
90
apps/cli/src/auth/callback-listener.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Localhost HTTP callback listener for CLI-to-browser sync flow.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /ping → reachability check (web page preflight)
|
||||
* GET /callback → receives sync token via ?token= query param
|
||||
* OPTIONS * → CORS preflight for claudemesh.com
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
export interface CallbackListener {
|
||||
/** Port the server is listening on. */
|
||||
port: number;
|
||||
/** Resolves when the /callback endpoint receives a token. */
|
||||
token: Promise<string>;
|
||||
/** Shut down the server. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a localhost HTTP server on a random OS-assigned port.
|
||||
* Returns the port and a promise that resolves with the sync token.
|
||||
*/
|
||||
export function startCallbackListener(): Promise<CallbackListener> {
|
||||
return new Promise((resolveStart) => {
|
||||
let resolveToken: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((r) => {
|
||||
resolveToken = r;
|
||||
});
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reachability check — web page calls this before redirecting
|
||||
if (url.pathname === "/ping") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync token callback
|
||||
if (url.pathname === "/callback") {
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end(
|
||||
"<html><body><h2>Done! You can close this tab.</h2><p>Launching claudemesh...</p></body></html>",
|
||||
);
|
||||
resolveToken(token);
|
||||
// Close server after a short delay to ensure response is sent
|
||||
setTimeout(() => server.close(), 500);
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Missing token");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolveStart({
|
||||
port: addr.port,
|
||||
token: tokenPromise,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
4
apps/cli/src/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { startCallbackListener, type CallbackListener } from "./callback-listener";
|
||||
export { openBrowser } from "./open-browser";
|
||||
export { generatePairingCode } from "./pairing-code";
|
||||
export { syncWithBroker, type SyncResult } from "./sync-with-broker";
|
||||
33
apps/cli/src/auth/open-browser.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-platform browser opener.
|
||||
* Respects BROWSER env var. Falls back to platform-specific launcher.
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser.
|
||||
* Returns true if the command succeeded, false otherwise.
|
||||
* Non-fatal — callers should show the URL as fallback.
|
||||
*/
|
||||
export function openBrowser(url: string): Promise<boolean> {
|
||||
// Validate URL
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const quoted = JSON.stringify(url);
|
||||
const browserCmd = process.env.BROWSER;
|
||||
|
||||
const cmd = browserCmd
|
||||
? `${browserCmd} ${quoted}`
|
||||
: process.platform === "darwin"
|
||||
? `open ${quoted}`
|
||||
: process.platform === "win32"
|
||||
? `rundll32 url.dll,FileProtocolHandler ${quoted}`
|
||||
: `xdg-open ${quoted}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
17
apps/cli/src/auth/pairing-code.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generate a short pairing code for CLI-to-browser visual confirmation.
|
||||
* Excludes ambiguous characters (0/O, 1/l/I) for readability.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
/**
|
||||
* Generate a 4-character alphanumeric pairing code.
|
||||
* Example output: "A3Kx", "Hn7v", "pQ4m"
|
||||
*/
|
||||
export function generatePairingCode(): string {
|
||||
const bytes = randomBytes(4);
|
||||
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
|
||||
}
|
||||
83
apps/cli/src/auth/sync-with-broker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Call the broker's POST /cli-sync endpoint to sync dashboard meshes.
|
||||
*
|
||||
* Takes a sync JWT (from the browser callback) and a freshly generated
|
||||
* ed25519 keypair. The broker creates member rows and returns mesh details.
|
||||
*/
|
||||
|
||||
export interface SyncResult {
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync meshes from dashboard via broker.
|
||||
*
|
||||
* @param syncToken - JWT from the browser sync flow
|
||||
* @param peerPubkey - ed25519 public key hex (64 chars)
|
||||
* @param displayName - display name for the new member
|
||||
* @param brokerBaseUrl - HTTPS base URL of the broker (derived from WSS URL)
|
||||
*/
|
||||
export async function syncWithBroker(
|
||||
syncToken: string,
|
||||
peerPubkey: string,
|
||||
displayName: string,
|
||||
brokerBaseUrl?: string,
|
||||
): Promise<SyncResult> {
|
||||
// Default broker URL — derive HTTPS from WSS
|
||||
const base = brokerBaseUrl ?? deriveHttpUrl(
|
||||
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
);
|
||||
|
||||
const res = await fetch(`${base}/cli-sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sync_token: syncToken,
|
||||
peer_pubkey: peerPubkey,
|
||||
display_name: displayName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
let msg: string;
|
||||
try {
|
||||
msg = JSON.parse(body).error ?? body;
|
||||
} catch {
|
||||
msg = body;
|
||||
}
|
||||
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
|
||||
|
||||
if (!body.ok) {
|
||||
throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
return {
|
||||
account_id: body.account_id!,
|
||||
meshes: body.meshes!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WSS broker URL to an HTTPS base URL.
|
||||
* wss://ic.claudemesh.com/ws → https://ic.claudemesh.com
|
||||
* ws://localhost:3001/ws → http://localhost:3001
|
||||
*/
|
||||
function deriveHttpUrl(wssUrl: string): string {
|
||||
const url = new URL(wssUrl);
|
||||
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
||||
// Remove /ws path suffix
|
||||
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
|
||||
// Remove trailing slash
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
65
apps/cli/src/commands/connect-telegram.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run 'claudemesh join' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mesh = config.meshes[0]!;
|
||||
const linkOnly = args.includes("--link");
|
||||
|
||||
// Convert WS broker URL to HTTP
|
||||
const brokerHttp = mesh.brokerUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace("/ws", "");
|
||||
|
||||
console.log("Requesting Telegram connect token...");
|
||||
|
||||
const res = await fetch(`${brokerHttp}/tg/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
pubkey: mesh.pubkey,
|
||||
secretKey: mesh.secretKey,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { token, deepLink } = (await res.json()) as {
|
||||
token: string;
|
||||
deepLink: string;
|
||||
};
|
||||
|
||||
if (linkOnly) {
|
||||
console.log(deepLink);
|
||||
return;
|
||||
}
|
||||
|
||||
// Print QR code using simple block characters
|
||||
console.log("\n Connect Telegram to your mesh:\n");
|
||||
console.log(` ${deepLink}\n`);
|
||||
console.log(" Open this link on your phone, or scan the QR code");
|
||||
console.log(" with your Telegram camera.\n");
|
||||
|
||||
// Try to generate QR with qrcode-terminal if available
|
||||
try {
|
||||
const QRCode = require("qrcode-terminal");
|
||||
QRCode.generate(deepLink, { small: true }, (code: string) => {
|
||||
console.log(code);
|
||||
});
|
||||
} catch {
|
||||
// qrcode-terminal not available, link is enough
|
||||
console.log(" (Install qrcode-terminal for QR code display)");
|
||||
}
|
||||
}
|
||||
59
apps/cli/src/commands/connect.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
|
||||
*
|
||||
* Opens a connection to one mesh, runs a callback, then closes cleanly.
|
||||
* The caller never deals with connect/close lifecycle.
|
||||
*/
|
||||
|
||||
import { hostname } from "node:os";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { loadConfig } from "../state/config";
|
||||
import type { JoinedMesh } from "../state/config";
|
||||
|
||||
export interface ConnectOpts {
|
||||
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
|
||||
meshSlug?: string | null;
|
||||
/** Display name for this session. Defaults to hostname-pid. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export async function withMesh<T>(
|
||||
opts: ConnectOpts,
|
||||
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let mesh: JoinedMesh;
|
||||
if (opts.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
|
||||
if (!found) {
|
||||
console.error(
|
||||
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
mesh = found;
|
||||
} else if (config.meshes.length === 1) {
|
||||
mesh = config.meshes[0]!;
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
const client = new BrokerClient(mesh, { displayName });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await fn(client, mesh);
|
||||
return result;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
39
apps/cli/src/commands/create.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `claudemesh create` — Create a new mesh with an optional template.
|
||||
* Lists available templates if --list-templates is passed.
|
||||
*/
|
||||
import { listTemplates, getTemplate } from "../templates/index.js";
|
||||
|
||||
export function runCreate(args: Record<string, unknown>): void {
|
||||
if (args["list-templates"]) {
|
||||
console.log("Available mesh templates:\n");
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`);
|
||||
console.log(` ${t.description}`);
|
||||
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
|
||||
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = args.template as string | undefined;
|
||||
if (templateName) {
|
||||
const template = getTemplate(templateName);
|
||||
if (!template) {
|
||||
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Template "${template.name}" loaded:`);
|
||||
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
|
||||
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
|
||||
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
|
||||
console.log();
|
||||
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
|
||||
// Future: wire into actual mesh creation API
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Usage: claudemesh create --template <name>");
|
||||
console.log(" claudemesh create --list-templates");
|
||||
}
|
||||
3
apps/cli/src/commands/disconnect-telegram.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function disconnectTelegram(): Promise<void> {
|
||||
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
|
||||
}
|
||||
60
apps/cli/src/commands/inbox.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `claudemesh inbox` — read pending peer messages.
|
||||
*
|
||||
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { InboundPush } from "../ws/client";
|
||||
|
||||
export interface InboxFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
wait?: number;
|
||||
}
|
||||
|
||||
function formatMessage(msg: InboundPush, useColor: boolean): string {
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
|
||||
const from = msg.senderPubkey.slice(0, 8);
|
||||
const time = new Date(msg.createdAt).toLocaleTimeString();
|
||||
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
|
||||
|
||||
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
|
||||
}
|
||||
|
||||
export async function runInbox(flags: InboxFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const waitMs = (flags.wait ?? 1) * 1000;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
// Wait briefly for broker to push any held messages.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||
|
||||
const messages = client.drainPushBuffer();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(dim(`No messages on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
|
||||
console.log("");
|
||||
for (const msg of messages) {
|
||||
console.log(formatMessage(msg, useColor));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
58
apps/cli/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||
*
|
||||
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export interface InfoFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const [brokerInfo, peers, state] = await Promise.all([
|
||||
client.meshInfo(),
|
||||
client.listPeers(),
|
||||
client.listState(),
|
||||
]);
|
||||
|
||||
const output = {
|
||||
slug: mesh.slug,
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
brokerUrl: mesh.brokerUrl,
|
||||
displayName: config.displayName ?? null,
|
||||
peerCount: peers.length,
|
||||
stateCount: state.length,
|
||||
...(brokerInfo ?? {}),
|
||||
};
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
|
||||
console.log(dim(` mesh: ${mesh.meshId}`));
|
||||
console.log(dim(` member: ${mesh.memberId}`));
|
||||
console.log(` peers: ${peers.length} connected`);
|
||||
console.log(` state: ${state.length} keys`);
|
||||
if (brokerInfo && typeof brokerInfo === "object") {
|
||||
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
||||
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -212,6 +213,92 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||
*/
|
||||
const CLAUDEMESH_TOOLS = [
|
||||
"mcp__claudemesh__cancel_scheduled",
|
||||
"mcp__claudemesh__check_messages",
|
||||
"mcp__claudemesh__claim_task",
|
||||
"mcp__claudemesh__complete_task",
|
||||
"mcp__claudemesh__create_stream",
|
||||
"mcp__claudemesh__create_task",
|
||||
"mcp__claudemesh__delete_file",
|
||||
"mcp__claudemesh__file_status",
|
||||
"mcp__claudemesh__forget",
|
||||
"mcp__claudemesh__get_context",
|
||||
"mcp__claudemesh__get_file",
|
||||
"mcp__claudemesh__get_state",
|
||||
"mcp__claudemesh__grant_file_access",
|
||||
"mcp__claudemesh__graph_execute",
|
||||
"mcp__claudemesh__graph_query",
|
||||
"mcp__claudemesh__join_group",
|
||||
"mcp__claudemesh__leave_group",
|
||||
"mcp__claudemesh__list_collections",
|
||||
"mcp__claudemesh__list_contexts",
|
||||
"mcp__claudemesh__list_files",
|
||||
"mcp__claudemesh__list_peers",
|
||||
"mcp__claudemesh__list_scheduled",
|
||||
"mcp__claudemesh__list_state",
|
||||
"mcp__claudemesh__list_streams",
|
||||
"mcp__claudemesh__list_tasks",
|
||||
"mcp__claudemesh__mesh_execute",
|
||||
"mcp__claudemesh__mesh_info",
|
||||
"mcp__claudemesh__mesh_query",
|
||||
"mcp__claudemesh__mesh_schema",
|
||||
"mcp__claudemesh__message_status",
|
||||
"mcp__claudemesh__ping_mesh",
|
||||
"mcp__claudemesh__publish",
|
||||
"mcp__claudemesh__recall",
|
||||
"mcp__claudemesh__remember",
|
||||
"mcp__claudemesh__schedule_reminder",
|
||||
"mcp__claudemesh__send_message",
|
||||
"mcp__claudemesh__set_state",
|
||||
"mcp__claudemesh__set_status",
|
||||
"mcp__claudemesh__set_summary",
|
||||
"mcp__claudemesh__share_context",
|
||||
"mcp__claudemesh__share_file",
|
||||
"mcp__claudemesh__subscribe",
|
||||
"mcp__claudemesh__vector_delete",
|
||||
"mcp__claudemesh__vector_search",
|
||||
"mcp__claudemesh__vector_store",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||
* Merges into any existing list — never overwrites other entries.
|
||||
* Returns which tools were added vs already present.
|
||||
*/
|
||||
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||
if (toAdd.length > 0) {
|
||||
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claudemesh tools from allowedTools.
|
||||
* Leaves all other entries intact. Returns count removed.
|
||||
*/
|
||||
function uninstallAllowedTools(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||
const kept = existing.filter((t) => !toolSet.has(t));
|
||||
const removed = existing.length - kept.length;
|
||||
if (removed > 0) {
|
||||
settings.allowedTools = kept;
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||
* idempotent on the command string. Returns counts for reporting.
|
||||
@@ -321,6 +408,26 @@ export function runInstall(args: string[] = []): void {
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
@@ -345,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = loadConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh join <invite-url>")}` +
|
||||
dim(" — join an existing mesh"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||
@@ -375,6 +505,20 @@ export function runUninstall(): void {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||
*
|
||||
* Flags are defined in index.ts (citty command) — that is the source of
|
||||
* truth. This file receives already-parsed flags and rawArgs.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
||||
* 2. If --join: run join flow first (accepts token or URL)
|
||||
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||
* 2. If --join: run join flow first
|
||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||
@@ -11,80 +14,29 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
||||
import { tmpdir, hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir, hostname, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
// --- Arg parsing ---
|
||||
|
||||
interface LaunchArgs {
|
||||
name: string | null;
|
||||
role: string | null;
|
||||
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
|
||||
joinLink: string | null;
|
||||
meshSlug: string | null;
|
||||
messageMode: "push" | "inbox" | "off" | null;
|
||||
quiet: boolean;
|
||||
skipPermConfirm: boolean;
|
||||
claudeArgs: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): LaunchArgs {
|
||||
const result: LaunchArgs = {
|
||||
name: null,
|
||||
role: null,
|
||||
groups: null,
|
||||
joinLink: null,
|
||||
meshSlug: null,
|
||||
messageMode: null,
|
||||
quiet: false,
|
||||
skipPermConfirm: false,
|
||||
claudeArgs: [],
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--name" && i + 1 < argv.length) {
|
||||
result.name = argv[++i]!;
|
||||
} else if (arg.startsWith("--name=")) {
|
||||
result.name = arg.slice("--name=".length);
|
||||
} else if (arg === "--role" && i + 1 < argv.length) {
|
||||
result.role = argv[++i]!;
|
||||
} else if (arg.startsWith("--role=")) {
|
||||
result.role = arg.slice("--role=".length);
|
||||
} else if (arg === "--groups" && i + 1 < argv.length) {
|
||||
result.groups = argv[++i]!;
|
||||
} else if (arg.startsWith("--groups=")) {
|
||||
result.groups = arg.slice("--groups=".length);
|
||||
} else if (arg === "--join" && i + 1 < argv.length) {
|
||||
result.joinLink = argv[++i]!;
|
||||
} else if (arg.startsWith("--join=")) {
|
||||
result.joinLink = arg.slice("--join=".length);
|
||||
} else if (arg === "--mesh" && i + 1 < argv.length) {
|
||||
result.meshSlug = argv[++i]!;
|
||||
} else if (arg.startsWith("--mesh=")) {
|
||||
result.meshSlug = arg.slice("--mesh=".length);
|
||||
} else if (arg === "--inbox") {
|
||||
result.messageMode = "inbox";
|
||||
} else if (arg === "--no-messages") {
|
||||
result.messageMode = "off";
|
||||
} else if (arg === "--quiet") {
|
||||
result.quiet = true;
|
||||
} else if (arg === "-y" || arg === "--yes") {
|
||||
result.skipPermConfirm = true;
|
||||
} else if (arg === "--") {
|
||||
result.claudeArgs.push(...argv.slice(i + 1));
|
||||
break;
|
||||
} else {
|
||||
result.claudeArgs.push(arg);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
name?: string;
|
||||
role?: string;
|
||||
groups?: string;
|
||||
join?: string;
|
||||
mesh?: string;
|
||||
"message-mode"?: string;
|
||||
"system-prompt"?: string;
|
||||
resume?: string;
|
||||
continue?: boolean;
|
||||
yes?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
// --- Interactive mesh picker ---
|
||||
@@ -151,12 +103,12 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
console.log(yellow(bold(" Autonomous mode")));
|
||||
console.log("");
|
||||
console.log(" Claude will send and receive peer messages without asking");
|
||||
console.log(" you first. Peers exchange text only — no file access,");
|
||||
console.log(" no tool calls, no code execution.");
|
||||
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||
console.log("");
|
||||
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
||||
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -206,8 +158,28 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
|
||||
|
||||
// --- Main ---
|
||||
|
||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
const args = parseArgs(extraArgs);
|
||||
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||
// Extract args that follow "--" — passed straight through to claude.
|
||||
const dashIdx = rawArgs.indexOf("--");
|
||||
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||
|
||||
// Normalise flags into the internal shape used below.
|
||||
const args = {
|
||||
name: flags.name ?? null,
|
||||
role: flags.role ?? null,
|
||||
groups: flags.groups ?? null,
|
||||
joinLink: flags.join ?? null,
|
||||
meshSlug: flags.mesh ?? null,
|
||||
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||
: null),
|
||||
systemPrompt: flags["system-prompt"] ?? null,
|
||||
resume: flags.resume ?? null,
|
||||
continueSession: flags.continue ?? false,
|
||||
quiet: flags.quiet ?? false,
|
||||
skipPermConfirm: flags.yes ?? false,
|
||||
claudeArgs: claudePassthrough,
|
||||
};
|
||||
|
||||
// 1. If --join, run join flow first.
|
||||
if (args.joinLink) {
|
||||
@@ -245,10 +217,85 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = loadConfig();
|
||||
let justSynced = false;
|
||||
|
||||
if (config.meshes.length === 0 && !args.joinLink) {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
|
||||
console.log(` Opening browser to sign in...\n`);
|
||||
|
||||
const opened = await openBrowser(url);
|
||||
if (!opened) {
|
||||
console.log(` Couldn't open browser automatically.`);
|
||||
}
|
||||
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("\n Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const { generateKeypair } = await import("../crypto/keypair");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { saveConfig } = await import("../state/config");
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
justSynced = true;
|
||||
|
||||
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(
|
||||
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
|
||||
);
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -277,7 +324,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
if (!args.quiet) {
|
||||
if (!args.quiet && !justSynced) {
|
||||
if (role === null) {
|
||||
const answer = await askLine(" Role (optional): ");
|
||||
if (answer) role = answer;
|
||||
@@ -312,12 +359,63 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Clean up stale mesh MCP entries from crashed sessions
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
if (existsSync(claudeConfigPath)) {
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
let cleaned = 0;
|
||||
for (const key of Object.keys(mcpServers)) {
|
||||
if (!key.startsWith("mesh:")) continue;
|
||||
const meta = mcpServers[key]?._meshSession;
|
||||
if (!meta?.pid) continue;
|
||||
// Check if the PID is still alive
|
||||
try {
|
||||
process.kill(meta.pid, 0); // signal 0 = check existence
|
||||
} catch {
|
||||
// PID is dead — remove stale entry
|
||||
delete mcpServers[key];
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
if (cleaned > 0) {
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// --- Fetch deployed services for native MCP entries ---
|
||||
let serviceCatalog: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: object }>;
|
||||
deployed_by: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const tmpClient = new BrokerClient(mesh, { displayName });
|
||||
await tmpClient.connect();
|
||||
// Wait briefly for hello_ack with service catalog
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
serviceCatalog = tmpClient.serviceCatalog;
|
||||
tmpClient.close();
|
||||
} catch {
|
||||
// Non-fatal — launch without native service entries
|
||||
if (!args.quiet) {
|
||||
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||
const sessionConfig: Config = {
|
||||
version: 1,
|
||||
meshes: [mesh],
|
||||
displayName,
|
||||
...(role ? { role } : {}),
|
||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||
messageMode,
|
||||
};
|
||||
@@ -336,6 +434,59 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Install native MCP entries for deployed mesh services ---
|
||||
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
|
||||
|
||||
if (serviceCatalog.length > 0) {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
|
||||
// Read-modify-write: only touch mesh:* entries in mcpServers
|
||||
let claudeConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
} catch {
|
||||
claudeConfig = {};
|
||||
}
|
||||
|
||||
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Session-scoped key: mesh:<service>:<sessionId>
|
||||
const sessionTag = `${process.pid}`;
|
||||
|
||||
for (const svc of serviceCatalog) {
|
||||
if (svc.status !== "running") continue;
|
||||
const entryKey = `mesh:${svc.name}:${sessionTag}`;
|
||||
const entry = {
|
||||
command: "claudemesh",
|
||||
args: ["mcp", "--service", svc.name],
|
||||
env: {
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
},
|
||||
_meshSession: {
|
||||
pid: process.pid,
|
||||
meshSlug: mesh.slug,
|
||||
serviceName: svc.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
mcpServers[entryKey] = entry;
|
||||
meshMcpEntries.push({ key: entryKey, entry });
|
||||
}
|
||||
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
if (!args.quiet && meshMcpEntries.length > 0) {
|
||||
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
|
||||
for (const { key } of meshMcpEntries) {
|
||||
const svcName = key.split(":")[1];
|
||||
const svc = serviceCatalog.find(s => s.name === svcName);
|
||||
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
|
||||
// Strip any user-supplied --dangerously flags to avoid duplicates.
|
||||
const filtered: string[] = [];
|
||||
@@ -347,10 +498,23 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
filtered.push(args.claudeArgs[i]!);
|
||||
}
|
||||
// --dangerously-skip-permissions is only added when the user explicitly
|
||||
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||
// This keeps permissions tight for multi-person meshes.
|
||||
// Session identity: --resume reuses existing session, otherwise generate new.
|
||||
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
|
||||
const isResume = args.resume !== null || args.continueSession;
|
||||
const claudeSessionId = isResume ? undefined : randomUUID();
|
||||
|
||||
const claudeArgs = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
"--dangerously-skip-permissions",
|
||||
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||
...(args.resume ? ["--resume", args.resume] : []),
|
||||
...(args.continueSession ? ["--continue"] : []),
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
|
||||
@@ -362,11 +526,29 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
...process.env,
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
|
||||
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
|
||||
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
|
||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Cleanup on exit.
|
||||
const cleanup = (): void => {
|
||||
// Remove mesh MCP entries from ~/.claude.json
|
||||
if (meshMcpEntries.length > 0) {
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
for (const { key } of meshMcpEntries) {
|
||||
delete mcpServers[key];
|
||||
}
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
// Existing tmpdir cleanup
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
|
||||
63
apps/cli/src/commands/memory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||
* `claudemesh recall <query>` — search mesh memory.
|
||||
*
|
||||
* Useful for AI agents using bash when the MCP server isn't active.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface MemoryFlags {
|
||||
mesh?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||
const tags = flags.tags
|
||||
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||
} else {
|
||||
console.error("✗ Failed to store memory");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
55
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
|
||||
if (cwdStr) console.log(` ${cwdStr}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
114
apps/cli/src/commands/profile.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `claudemesh profile` — view or edit your member profile.
|
||||
*
|
||||
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
export interface ProfileFlags {
|
||||
mesh?: string;
|
||||
"role-tag"?: string;
|
||||
groups?: string;
|
||||
"message-mode"?: string;
|
||||
name?: string;
|
||||
member?: string; // admin only: edit another member
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runProfile(flags: ProfileFlags): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick mesh
|
||||
const mesh = flags.mesh
|
||||
? config.meshes.find(m => m.slug === flags.mesh)
|
||||
: config.meshes[0]!;
|
||||
|
||||
if (!mesh) {
|
||||
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive broker HTTP URL from WSS URL
|
||||
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
|
||||
|
||||
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
|
||||
|
||||
if (hasEdits) {
|
||||
// PATCH member profile
|
||||
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.name !== undefined) body.displayName = flags.name;
|
||||
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
|
||||
if (flags.groups !== undefined) {
|
||||
body.groups = flags.groups.split(",").map(s => {
|
||||
const [name, role] = s.trim().split(":");
|
||||
return role ? { name: name!, role } : { name: name! };
|
||||
});
|
||||
}
|
||||
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
|
||||
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Member-Id": mesh.memberId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await res.json() as Record<string, unknown>;
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.ok) {
|
||||
console.log(green("✓ Profile updated"));
|
||||
const member = result.member as Record<string, unknown>;
|
||||
printProfile(member, dim);
|
||||
} else {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// GET members list, show current user's profile
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
|
||||
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = result.members?.find(m => m.id === mesh.memberId);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(me ?? {}, null, 2));
|
||||
} else if (me) {
|
||||
printProfile(me, dim);
|
||||
} else {
|
||||
console.log("Member not found in mesh.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
|
||||
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
|
||||
const groupStr = groups?.length
|
||||
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
|
||||
: dim("(none)");
|
||||
|
||||
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
|
||||
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
|
||||
console.log(` Groups: ${groupStr}`);
|
||||
console.log(` Messages: ${member.messageMode ?? "push"}`);
|
||||
console.log(` Access: ${member.permission ?? "member"}`);
|
||||
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
|
||||
}
|
||||
142
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
cron?: string; // 5-field cron expression for recurring
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseDuration(raw: string): number | null {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = (m[2] ?? "s").toLowerCase();
|
||||
if (unit.startsWith("d")) return n * 86_400_000;
|
||||
if (unit.startsWith("h")) return n * 3_600_000;
|
||||
if (unit.startsWith("m")) return n * 60_000;
|
||||
return n * 1_000;
|
||||
}
|
||||
|
||||
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
if (flags.in) {
|
||||
const ms = parseDuration(flags.in);
|
||||
if (ms === null) return null;
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
return isNaN(ts) ? null : ts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
targetSpec = flags.to;
|
||||
} else {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
} else {
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||
*
|
||||
* <to> can be:
|
||||
* - a display name ("Mou")
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { Priority } from "../ws/client";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||
const priority: Priority =
|
||||
flags.priority === "now" ? "now"
|
||||
: flags.priority === "low" ? "low"
|
||||
: "next";
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Resolve display name → pubkey for direct messages.
|
||||
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||
let targetSpec = to;
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
// Treat as display name — look up pubkey via list_peers.
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
|
||||
const result = await client.send(targetSpec, message, priority);
|
||||
if (result.ok) {
|
||||
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||
} else {
|
||||
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
75
apps/cli/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `claudemesh state get <key>` — read a shared state value
|
||||
* `claudemesh state set <key> <value>` — write a shared state value
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
console.log(dim(`(not set)`));
|
||||
return;
|
||||
}
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||
console.log(val);
|
||||
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
console.log(`${bold(e.key)}: ${val}`);
|
||||
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
88
apps/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `claudemesh sync` — re-sync meshes from dashboard account.
|
||||
*
|
||||
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
|
||||
* merges new meshes into local config.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hostname } from "node:os";
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`Opening browser to sync meshes...`);
|
||||
console.log(dim(`Visit: ${url}`));
|
||||
await openBrowser(url);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question("Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use existing keypair from first mesh, or generate new
|
||||
const keypair = config.meshes.length > 0
|
||||
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
|
||||
: await generateKeypair();
|
||||
|
||||
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
|
||||
|
||||
// Merge: add new meshes, skip duplicates
|
||||
let added = 0;
|
||||
for (const m of result.meshes) {
|
||||
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
}
|
||||
90
apps/cli/src/crypto/file-crypto.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* File encryption for claudemesh E2E file sharing.
|
||||
*
|
||||
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
|
||||
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
|
||||
* Key opening: crypto_box_seal_open with own X25519 keypair.
|
||||
*/
|
||||
|
||||
import { ensureSodium } from "./keypair";
|
||||
|
||||
export interface EncryptedFile {
|
||||
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
|
||||
nonce: string; // base64 24-byte nonce
|
||||
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt file bytes with a fresh random symmetric key.
|
||||
* Returns ciphertext, nonce (base64), and the plaintext Kf.
|
||||
*/
|
||||
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
|
||||
const sodium = await ensureSodium();
|
||||
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||
return {
|
||||
ciphertext,
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt file bytes with the symmetric key Kf.
|
||||
* Returns null if decryption fails.
|
||||
*/
|
||||
export async function decryptFile(
|
||||
ciphertext: Uint8Array,
|
||||
nonceB64: string,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
|
||||
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
|
||||
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
|
||||
* Returns base64 sealed box.
|
||||
*/
|
||||
export async function sealKeyForPeer(
|
||||
kf: Uint8Array,
|
||||
recipientPubkeyHex: string,
|
||||
): Promise<string> {
|
||||
const sodium = await ensureSodium();
|
||||
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(recipientPubkeyHex),
|
||||
);
|
||||
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
|
||||
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
|
||||
* Returns the 32-byte Kf or null if decryption fails.
|
||||
*/
|
||||
export async function openSealedKey(
|
||||
sealedB64: string,
|
||||
myPubkeyHex: string,
|
||||
mySecretKeyHex: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(myPubkeyHex),
|
||||
);
|
||||
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||
sodium.from_hex(mySecretKeyHex),
|
||||
);
|
||||
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
|
||||
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* claudemesh-cli entry point.
|
||||
*
|
||||
* Uses citty to define commands and flags. --help is generated from
|
||||
* the command definitions — the flag list here IS the documentation.
|
||||
*
|
||||
* Dispatches between two modes:
|
||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||
* - `claudemesh <subcommand>` → CLI subcommand
|
||||
*
|
||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
||||
*/
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { startMcpServer } from "./mcp/server";
|
||||
import { runInstall, runUninstall } from "./commands/install";
|
||||
import { runJoin } from "./commands/join";
|
||||
@@ -19,98 +21,319 @@ import { runLaunch } from "./commands/launch";
|
||||
import { runStatus } from "./commands/status";
|
||||
import { runDoctor } from "./commands/doctor";
|
||||
import { runWelcome } from "./commands/welcome";
|
||||
import { runPeers } from "./commands/peers";
|
||||
import { runSend } from "./commands/send";
|
||||
import { runInbox } from "./commands/inbox";
|
||||
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||
import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { runCreate } from "./commands/create";
|
||||
import { runSync } from "./commands/sync";
|
||||
import { runProfile, type ProfileFlags } from "./commands/profile";
|
||||
import { connectTelegram } from "./commands/connect-telegram";
|
||||
import { disconnectTelegram } from "./commands/disconnect-telegram";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
||||
|
||||
Usage:
|
||||
claudemesh <command> [args]
|
||||
|
||||
Commands:
|
||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
||||
(add --no-hooks for bare MCP registration)
|
||||
uninstall Remove MCP server + hooks
|
||||
launch [opts] Launch Claude Code with real-time push messages
|
||||
--name <name> Display name for this session
|
||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
||||
--join <url> Join a mesh before launching
|
||||
--quiet Skip the info banner
|
||||
-- <args> Pass remaining args to claude
|
||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||
list Show all joined meshes
|
||||
leave <slug> Leave a joined mesh
|
||||
status Health report: broker reachability per joined mesh
|
||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
||||
--help, -h Show this help
|
||||
--version, -v Show the CLI version
|
||||
|
||||
Environment:
|
||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
||||
`;
|
||||
|
||||
const cmd = process.argv[2];
|
||||
const args = process.argv.slice(3);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
switch (cmd) {
|
||||
case "mcp":
|
||||
await startMcpServer();
|
||||
return;
|
||||
case "install":
|
||||
runInstall(args);
|
||||
return;
|
||||
case "uninstall":
|
||||
runUninstall();
|
||||
return;
|
||||
case "hook":
|
||||
await runHook(args);
|
||||
return;
|
||||
case "launch":
|
||||
await runLaunch(args);
|
||||
return;
|
||||
case "join":
|
||||
await runJoin(args);
|
||||
return;
|
||||
case "list":
|
||||
runList();
|
||||
return;
|
||||
case "leave":
|
||||
runLeave(args);
|
||||
return;
|
||||
case "status":
|
||||
await runStatus();
|
||||
return;
|
||||
case "doctor":
|
||||
await runDoctor();
|
||||
return;
|
||||
case "seed-test-mesh":
|
||||
runSeedTestMesh(args);
|
||||
return;
|
||||
case "--version":
|
||||
case "-v":
|
||||
case "version":
|
||||
console.log(VERSION);
|
||||
return;
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "help":
|
||||
console.log(HELP);
|
||||
return;
|
||||
case undefined:
|
||||
runWelcome();
|
||||
return;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
console.error("Run `claudemesh --help` for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
process.exit(1);
|
||||
const launch = defineCommand({
|
||||
meta: {
|
||||
name: "launch",
|
||||
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Display name visible to other peers",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
|
||||
},
|
||||
groups: {
|
||||
type: "string",
|
||||
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
|
||||
},
|
||||
mesh: {
|
||||
type: "string",
|
||||
description: "Mesh slug (interactive picker if omitted and >1 joined)",
|
||||
},
|
||||
join: {
|
||||
type: "string",
|
||||
description: "Join a mesh via invite URL before launching",
|
||||
},
|
||||
"message-mode": {
|
||||
type: "string",
|
||||
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
|
||||
},
|
||||
"system-prompt": {
|
||||
type: "string",
|
||||
description: "Custom system prompt for this Claude session",
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "Skip the --dangerously-skip-permissions confirmation",
|
||||
default: false,
|
||||
},
|
||||
resume: {
|
||||
type: "string",
|
||||
alias: "r",
|
||||
description: "Resume a previous Claude Code session by ID, or pass `true` for interactive picker",
|
||||
},
|
||||
continue: {
|
||||
type: "boolean",
|
||||
alias: "c",
|
||||
description: "Continue the most recent conversation in this directory",
|
||||
default: false,
|
||||
},
|
||||
quiet: {
|
||||
type: "boolean",
|
||||
description: "Suppress banner and interactive prompts",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ args, rawArgs }) {
|
||||
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||
return runLaunch(args, rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const install = defineCommand({
|
||||
meta: {
|
||||
name: "install",
|
||||
description: "Register MCP server and status hooks with Claude Code",
|
||||
},
|
||||
args: {
|
||||
"no-hooks": {
|
||||
type: "boolean",
|
||||
description: "Register MCP server only, skip hooks",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ rawArgs }) {
|
||||
runInstall(rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const join = defineCommand({
|
||||
meta: {
|
||||
name: "join",
|
||||
description: "Join a mesh via invite URL or token",
|
||||
},
|
||||
args: {
|
||||
url: {
|
||||
type: "positional",
|
||||
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
return runJoin([args.url]);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = defineCommand({
|
||||
meta: {
|
||||
name: "leave",
|
||||
description: "Leave a joined mesh and remove its local keypair",
|
||||
},
|
||||
args: {
|
||||
slug: {
|
||||
type: "positional",
|
||||
description: "Mesh slug to leave (see `claudemesh list`)",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
runLeave([args.slug]);
|
||||
},
|
||||
});
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "claudemesh",
|
||||
version: VERSION,
|
||||
description: "Peer mesh for Claude Code sessions",
|
||||
},
|
||||
subCommands: {
|
||||
launch,
|
||||
create: defineCommand({
|
||||
meta: { name: "create", description: "Create a new mesh from a template" },
|
||||
args: {
|
||||
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
|
||||
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
|
||||
},
|
||||
run({ args }) { runCreate(args); },
|
||||
}),
|
||||
install,
|
||||
uninstall: defineCommand({
|
||||
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
|
||||
run() { runUninstall(); },
|
||||
}),
|
||||
join,
|
||||
list: defineCommand({
|
||||
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
|
||||
run() { runList(); },
|
||||
}),
|
||||
leave,
|
||||
peers: defineCommand({
|
||||
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runPeers(args); },
|
||||
}),
|
||||
send: defineCommand({
|
||||
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
|
||||
args: {
|
||||
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
|
||||
message: { type: "positional", description: "Message text", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
|
||||
},
|
||||
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||
}),
|
||||
inbox: defineCommand({
|
||||
meta: { name: "inbox", description: "Drain pending inbound messages" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||
},
|
||||
}),
|
||||
state: defineCommand({
|
||||
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
|
||||
args: {
|
||||
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
|
||||
key: { type: "positional", description: "State key (required for `get` and `set`)" },
|
||||
value: { type: "positional", description: "Value to store (required for `set`)" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.action === "list") {
|
||||
await runStateList(args);
|
||||
} else if (args.action === "get") {
|
||||
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||
await runStateGet(args, args.key);
|
||||
} else if (args.action === "set") {
|
||||
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||
await runStateSet(args, args.key, args.value);
|
||||
} else {
|
||||
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
info: defineCommand({
|
||||
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runInfo(args); },
|
||||
}),
|
||||
remember: defineCommand({
|
||||
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
|
||||
args: {
|
||||
content: { type: "positional", description: "Text to store", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRemember(args, args.content); },
|
||||
}),
|
||||
recall: defineCommand({
|
||||
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
|
||||
args: {
|
||||
query: { type: "positional", description: "Full-text search query", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRecall(args, args.query); },
|
||||
}),
|
||||
remind: defineCommand({
|
||||
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
|
||||
args: {
|
||||
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
|
||||
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
|
||||
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
|
||||
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
|
||||
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
|
||||
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
// Collect positional args from rawArgs (before any flags)
|
||||
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
sync: defineCommand({
|
||||
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||
args: {
|
||||
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
|
||||
},
|
||||
async run({ args }) { await runSync(args); },
|
||||
}),
|
||||
profile: defineCommand({
|
||||
meta: { name: "profile", description: "View or edit your member profile" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
|
||||
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
|
||||
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
|
||||
name: { type: "string", description: "Set display name" },
|
||||
member: { type: "string", description: "Edit another member (admin only)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runProfile(args as ProfileFlags); },
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
}),
|
||||
doctor: defineCommand({
|
||||
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
|
||||
async run() { await runDoctor(); },
|
||||
}),
|
||||
mcp: defineCommand({
|
||||
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
|
||||
async run() { await startMcpServer(); },
|
||||
}),
|
||||
"seed-test-mesh": defineCommand({
|
||||
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
|
||||
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||
}),
|
||||
hook: defineCommand({
|
||||
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
|
||||
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||
}),
|
||||
connect: defineCommand({
|
||||
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
disconnect: defineCommand({
|
||||
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await disconnectTelegram();
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
},
|
||||
run() {
|
||||
runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
|
||||
@@ -96,6 +96,48 @@ export const TOOLS: Tool[] = [
|
||||
required: ["status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_visible",
|
||||
description:
|
||||
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: {
|
||||
type: "boolean",
|
||||
description: "true to be visible (default), false to hide",
|
||||
},
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_profile",
|
||||
description:
|
||||
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
avatar: {
|
||||
type: "string",
|
||||
description: "Emoji or URL for your avatar",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
description: "One-liner about yourself",
|
||||
},
|
||||
capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What you can help with",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join_group",
|
||||
description:
|
||||
@@ -203,7 +245,7 @@ export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "share_file",
|
||||
description:
|
||||
"Share a persistent file with the mesh. All current and future peers can access it.",
|
||||
"Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -217,6 +259,10 @@ export const TOOLS: Tool[] = [
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
@@ -269,6 +315,18 @@ export const TOOLS: Tool[] = [
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grant_file_access",
|
||||
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: { type: "string", description: "File ID" },
|
||||
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
|
||||
},
|
||||
required: ["fileId", "to"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Vector tools ---
|
||||
{
|
||||
@@ -548,6 +606,43 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Scheduled messages ---
|
||||
{
|
||||
name: "schedule_reminder",
|
||||
description:
|
||||
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Message or reminder text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
|
||||
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_scheduled",
|
||||
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "cancel_scheduled",
|
||||
description: "Cancel a pending scheduled message before it fires.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Scheduled message ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh info ---
|
||||
{
|
||||
name: "mesh_info",
|
||||
@@ -556,6 +651,181 @@ 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",
|
||||
},
|
||||
persistent: {
|
||||
type: "boolean",
|
||||
description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false",
|
||||
},
|
||||
},
|
||||
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 as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." },
|
||||
description: { type: "string", description: "Short description of what the skill does" },
|
||||
instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for discoverability",
|
||||
},
|
||||
when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" },
|
||||
allowed_tools: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])",
|
||||
},
|
||||
model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" },
|
||||
context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" },
|
||||
agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" },
|
||||
user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" },
|
||||
argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" },
|
||||
},
|
||||
required: ["name", "description", "instructions"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_skill",
|
||||
description:
|
||||
"Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to load" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_skills",
|
||||
description:
|
||||
"Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search keyword (optional)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_skill",
|
||||
description:
|
||||
"Remove a skill you published from the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to remove" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Diagnostics ---
|
||||
{
|
||||
name: "ping_mesh",
|
||||
@@ -572,4 +842,179 @@ 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"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Service deployment tools ---
|
||||
|
||||
{
|
||||
name: "mesh_mcp_deploy",
|
||||
description: "Deploy an MCP server to the mesh from a zip file or git repo. Runs on the broker VPS, persists across peer sessions. Default scope: private (only you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Unique name for the server in this mesh" },
|
||||
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
|
||||
git_url: { type: "string", description: "Git repo URL" },
|
||||
git_branch: { type: "string", description: "Branch to clone (default: main)" },
|
||||
npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" },
|
||||
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
|
||||
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
|
||||
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
|
||||
network_allow: { type: "array", items: { type: "string" }, description: "Allowed outbound hosts (default: none)" },
|
||||
scope: { description: "Visibility: 'peer' (default), 'mesh', or {group/groups/role/peers}" },
|
||||
},
|
||||
required: ["server_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_undeploy",
|
||||
description: "Stop and remove a managed MCP server from the mesh.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_update",
|
||||
description: "Pull latest code and restart a git-sourced MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_logs",
|
||||
description: "View recent logs from a managed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, lines: { type: "number", description: "Lines (default: 50, max: 1000)" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_scope",
|
||||
description: "Get or set the visibility scope of a deployed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, scope: { description: "New scope to set. Omit to read current." } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_schema",
|
||||
description: "Inspect tool schemas for a deployed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, tool_name: { type: "string", description: "Specific tool (omit for all)" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_catalog",
|
||||
description: "List all deployed services in the mesh with status, scope, and tool count.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Skill deployment tools ---
|
||||
|
||||
{
|
||||
name: "mesh_skill_deploy",
|
||||
description: "Deploy a multi-file skill bundle from a zip or git repo.",
|
||||
inputSchema: { type: "object", properties: { file_id: { type: "string" }, git_url: { type: "string" }, git_branch: { type: "string" } } },
|
||||
},
|
||||
|
||||
// --- Vault tools ---
|
||||
|
||||
{
|
||||
name: "vault_set",
|
||||
description: "Store an encrypted credential in your vault. Reference in mesh_mcp_deploy with $vault:<key>.",
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string", description: "Secret value or local file path (for type=file)" }, type: { type: "string", enum: ["env", "file"] }, mount_path: { type: "string" }, description: { type: "string" } }, required: ["key", "value"] },
|
||||
},
|
||||
{
|
||||
name: "vault_list",
|
||||
description: "List your vault entries (keys and metadata only, no secret values).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "vault_delete",
|
||||
description: "Remove a credential from your vault.",
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
|
||||
},
|
||||
|
||||
// --- URL Watch tools ---
|
||||
|
||||
{
|
||||
name: "mesh_watch",
|
||||
description: "Watch a URL for changes. The broker polls it at the given interval and notifies you when the response changes. Works with any URL — websites (hash mode), JSON APIs (json mode), or status codes (status mode).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "URL to watch" },
|
||||
mode: { type: "string", enum: ["hash", "json", "status"], description: "Detection mode: hash (SHA-256 of body), json (extract jsonpath value), status (HTTP status code). Default: hash" },
|
||||
extract: { type: "string", description: "For json mode: dot path to extract (e.g. 'status' or 'data.deployments[0].status')" },
|
||||
interval: { type: "number", description: "Poll interval in seconds (min: 5, default: 30)" },
|
||||
notify_on: { type: "string", description: "When to notify: 'change' (default), 'match:<value>', 'not_match:<value>'" },
|
||||
headers: { type: "object", description: "Optional HTTP headers (e.g. for auth)" },
|
||||
label: { type: "string", description: "Human-readable label for this watch" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_unwatch",
|
||||
description: "Stop watching a URL.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { watch_id: { type: "string" } },
|
||||
required: ["watch_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_watches",
|
||||
description: "List your active URL watches.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
|
||||
export interface SetStatusArgs {
|
||||
status: PeerStatus;
|
||||
}
|
||||
|
||||
// --- Service deployment types ---
|
||||
|
||||
export type ServiceScope =
|
||||
| "peer"
|
||||
| "mesh"
|
||||
| { peers: string[] }
|
||||
| { group: string }
|
||||
| { groups: string[] }
|
||||
| { role: string };
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
type: "mcp" | "skill";
|
||||
description: string;
|
||||
status: string;
|
||||
tool_count: number;
|
||||
deployed_by: string;
|
||||
scope: ServiceScope;
|
||||
source_type: string;
|
||||
runtime?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VaultEntry {
|
||||
key: string;
|
||||
entry_type: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MeshMcpDeployArgs {
|
||||
server_name: string;
|
||||
file_id?: string;
|
||||
git_url?: string;
|
||||
git_branch?: string;
|
||||
env?: Record<string, string>;
|
||||
runtime?: "node" | "python" | "bun";
|
||||
memory_mb?: number;
|
||||
network_allow?: string[];
|
||||
scope?: ServiceScope;
|
||||
}
|
||||
|
||||
export interface VaultSetArgs {
|
||||
key: string;
|
||||
value: string;
|
||||
type?: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ export interface Config {
|
||||
version: 1;
|
||||
meshes: JoinedMesh[];
|
||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||
role?: string; // per-session role tag (display + hello)
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
||||
}
|
||||
|
||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
@@ -54,7 +56,7 @@ export function loadConfig(): Config {
|
||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode };
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
||||
17
apps/cli/src/templates/dev-team.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dev-team",
|
||||
"description": "Software development team with frontend, backend, and devops groups",
|
||||
"groups": [
|
||||
{ "name": "frontend", "roles": ["lead", "member"] },
|
||||
{ "name": "backend", "roles": ["lead", "member"] },
|
||||
{ "name": "devops", "roles": ["lead", "member"] },
|
||||
{ "name": "qa", "roles": ["lead", "member"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"sprint": "current",
|
||||
"deploy-frozen": "false",
|
||||
"pr-queue": "[]"
|
||||
},
|
||||
"suggestedRoles": ["lead", "member", "reviewer"],
|
||||
"systemPromptHint": "You are part of a dev team. Coordinate with @frontend, @backend, @devops groups. Check state keys for sprint status and deploy freezes before making changes."
|
||||
}
|
||||
30
apps/cli/src/templates/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import devTeam from "./dev-team.json" with { type: "json" };
|
||||
import research from "./research.json" with { type: "json" };
|
||||
import opsIncident from "./ops-incident.json" with { type: "json" };
|
||||
import simulation from "./simulation.json" with { type: "json" };
|
||||
import personal from "./personal.json" with { type: "json" };
|
||||
|
||||
export interface MeshTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
groups: Array<{ name: string; roles: string[] }>;
|
||||
stateKeys: Record<string, string>;
|
||||
suggestedRoles: string[];
|
||||
systemPromptHint: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: Record<string, MeshTemplate> = {
|
||||
"dev-team": devTeam,
|
||||
research,
|
||||
"ops-incident": opsIncident,
|
||||
simulation,
|
||||
personal,
|
||||
};
|
||||
|
||||
export function listTemplates(): MeshTemplate[] {
|
||||
return Object.values(TEMPLATES);
|
||||
}
|
||||
|
||||
export function getTemplate(name: string): MeshTemplate | undefined {
|
||||
return TEMPLATES[name];
|
||||
}
|
||||
17
apps/cli/src/templates/ops-incident.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ops-incident",
|
||||
"description": "Incident response team with oncall, comms, and engineering groups",
|
||||
"groups": [
|
||||
{ "name": "oncall", "roles": ["primary", "secondary"] },
|
||||
{ "name": "comms", "roles": ["lead", "scribe"] },
|
||||
{ "name": "engineering", "roles": ["lead", "responder"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"incident-status": "investigating",
|
||||
"severity": "unknown",
|
||||
"commander": "",
|
||||
"timeline": "[]"
|
||||
},
|
||||
"suggestedRoles": ["commander", "primary-oncall", "scribe", "responder"],
|
||||
"systemPromptHint": "INCIDENT MODE. Priority: now for all messages. Update incident-status state. Commander coordinates. Scribe maintains timeline. Engineering fixes."
|
||||
}
|
||||
11
apps/cli/src/templates/personal.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "personal",
|
||||
"description": "Private mesh for a single user — all sessions auto-join",
|
||||
"groups": [],
|
||||
"stateKeys": {
|
||||
"focus": "",
|
||||
"todos": "[]"
|
||||
},
|
||||
"suggestedRoles": [],
|
||||
"systemPromptHint": "Personal workspace. All your Claude Code sessions share this mesh. Use state keys to track focus and todos across sessions."
|
||||
}
|
||||
16
apps/cli/src/templates/research.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "research",
|
||||
"description": "Research and analysis team focused on deep investigation and knowledge sharing",
|
||||
"groups": [
|
||||
{ "name": "analysis", "roles": ["lead", "analyst"] },
|
||||
{ "name": "writing", "roles": ["lead", "writer", "reviewer"] },
|
||||
{ "name": "data", "roles": ["engineer", "analyst"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"research-topic": "",
|
||||
"phase": "exploration",
|
||||
"findings-count": "0"
|
||||
},
|
||||
"suggestedRoles": ["lead", "analyst", "writer", "reviewer"],
|
||||
"systemPromptHint": "You are part of a research team. Share findings via remember(), use recall() before starting new analysis. Coordinate phases through state keys."
|
||||
}
|
||||
17
apps/cli/src/templates/simulation.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "simulation",
|
||||
"description": "Load testing simulation with configurable time multiplier and user personas",
|
||||
"groups": [
|
||||
{ "name": "personas", "roles": ["admin", "user", "customer"] },
|
||||
{ "name": "observers", "roles": ["monitor", "analyst"] },
|
||||
{ "name": "control", "roles": ["orchestrator"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"clock-speed": "x1",
|
||||
"sim-status": "paused",
|
||||
"tick-count": "0",
|
||||
"scenario": ""
|
||||
},
|
||||
"suggestedRoles": ["orchestrator", "persona", "monitor"],
|
||||
"systemPromptHint": "SIMULATION MODE. Follow the clock-speed state for time multiplier. Act according to your persona role and the simulated time. Report actions to @observers."
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { env } from "../env";
|
||||
|
||||
const clients = new Map<string, BrokerClient>();
|
||||
let configDisplayName: string | undefined;
|
||||
let configGroups: Config["groups"] = [];
|
||||
|
||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
clients.set(mesh.meshId, client);
|
||||
try {
|
||||
await client.connect();
|
||||
// Auto-join groups declared at launch time (--groups flag or config).
|
||||
for (const g of configGroups ?? []) {
|
||||
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||
}
|
||||
} catch {
|
||||
// Connect failed → client is in "reconnecting" state, leave it
|
||||
// wired so tool calls can surface the status.
|
||||
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||
export async function startClients(config: Config): Promise<void> {
|
||||
configDisplayName = config.displayName;
|
||||
configGroups = config.groups ?? [];
|
||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||
}
|
||||
|
||||
|
||||
38
apps/runner/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# claudemesh runner — executes deployed MCP servers as child processes.
|
||||
# Multi-runtime: Node 22 + Python 3.12 + uv + Bun
|
||||
#
|
||||
# The runner supervisor (Node) listens on HTTP :7901 for commands from
|
||||
# the broker (load, call, unload, health, list_tools). Each deployed
|
||||
# MCP server runs as a child process with its own stdio pipe.
|
||||
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install Python 3.12 + uv (fast pip replacement) + git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv \
|
||||
curl ca-certificates git unzip \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -sf /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://bun.sh/install | bash \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /root/.bun/bin/bunx /usr/local/bin/bunx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the runner supervisor
|
||||
COPY supervisor.mjs /app/supervisor.mjs
|
||||
|
||||
# Services directory (shared volume with broker)
|
||||
RUN mkdir -p /var/claudemesh/services
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RUNNER_PORT=7901
|
||||
|
||||
EXPOSE 7901
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:7901/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "supervisor.mjs"]
|
||||
365
apps/runner/supervisor.mjs
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* claudemesh runner supervisor — manages MCP server child processes.
|
||||
*
|
||||
* HTTP API (called by broker):
|
||||
* POST /load { name, sourcePath, env, runtime } → spawn MCP, return tools
|
||||
* POST /call { name, tool, args } → route tool call
|
||||
* POST /unload { name } → kill process
|
||||
* GET /health → { ok, services }
|
||||
* GET /list { name? } → tools for a service
|
||||
*
|
||||
* Each MCP server is a child process with its own stdio pipe.
|
||||
* The supervisor talks MCP JSON-RPC over stdin/stdout to each child.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PORT = parseInt(process.env.RUNNER_PORT || "7901", 10);
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const LOG_BUFFER_SIZE = 500;
|
||||
|
||||
// --- Service registry ---
|
||||
|
||||
const services = new Map();
|
||||
let callIdCounter = 0;
|
||||
|
||||
// --- Runtime detection ---
|
||||
|
||||
function detectRuntime(sourcePath) {
|
||||
if (existsSync(join(sourcePath, "bun.lockb")) || existsSync(join(sourcePath, "bunfig.toml"))) return "bun";
|
||||
if (existsSync(join(sourcePath, "package.json"))) return "node";
|
||||
if (existsSync(join(sourcePath, "pyproject.toml")) || existsSync(join(sourcePath, "requirements.txt"))) return "python";
|
||||
return "node";
|
||||
}
|
||||
|
||||
function detectEntry(sourcePath, runtime) {
|
||||
if (runtime === "python") {
|
||||
for (const e of ["server.py", "src/server.py", "main.py", "src/main.py"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd: "python3", args: [e] };
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) return { cmd: "python3", args: ["-m", "server"] };
|
||||
return { cmd: "python3", args: ["server.py"] };
|
||||
}
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(sourcePath, "package.json"), "utf-8"));
|
||||
if (pkg.main) return { cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
|
||||
if (bin) return { cmd, args: [bin] };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const e of ["dist/index.js", "src/index.js", "src/index.ts", "index.js"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd, args: [e] };
|
||||
}
|
||||
return { cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// --- Install deps ---
|
||||
|
||||
function installDeps(sourcePath, runtime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd, args;
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip3"; args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else { cmd = "pip3"; args = ["install", "--no-cache-dir", "."]; }
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun"; args = ["install"];
|
||||
} else {
|
||||
cmd = "npm"; args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
const child = spawn(cmd, args, { cwd: sourcePath, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", d => { stderr += d.toString(); });
|
||||
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`${cmd} install exit ${code}: ${stderr.slice(-300)}`)));
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// --- MCP JSON-RPC ---
|
||||
|
||||
function sendMcpRequest(svc, method, params) {
|
||||
return new Promise(resolve => {
|
||||
if (!svc.process?.stdin?.writable) { resolve({ error: "not running" }); return; }
|
||||
const id = `c_${++callIdCounter}`;
|
||||
const timer = setTimeout(() => { svc.pending.delete(id); resolve({ error: "timeout" }); }, CALL_TIMEOUT_MS);
|
||||
svc.pending.set(id, { resolve, timer });
|
||||
try {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer); svc.pending.delete(id);
|
||||
resolve({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initMcp(svc) {
|
||||
const init = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05", capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
if (init.error) throw new Error(`init failed: ${init.error}`);
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
||||
}
|
||||
const tools = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (tools.error) throw new Error(`tools/list failed: ${tools.error}`);
|
||||
return tools.result?.tools ?? [];
|
||||
}
|
||||
|
||||
// --- Spawn ---
|
||||
|
||||
function spawnService(svc) {
|
||||
// npx/uvx packages have pre-resolved entry points
|
||||
let cmd, args;
|
||||
if (svc._pythonModule) {
|
||||
// Python MCPs: run via venv python -m <module>
|
||||
cmd = svc._venvPython;
|
||||
args = ["-m", svc._pythonModule];
|
||||
} else if (svc._npxBin) {
|
||||
cmd = "node";
|
||||
args = [svc._npxBin];
|
||||
} else {
|
||||
({ cmd, args } = detectEntry(svc.sourcePath, svc.runtime));
|
||||
}
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, ...svc.env, NODE_ENV: "production" },
|
||||
});
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
rl.on("line", line => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pending.has(String(msg.id))) {
|
||||
const p = svc.pending.get(String(msg.id));
|
||||
clearTimeout(p.timer); svc.pending.delete(String(msg.id));
|
||||
p.resolve(msg.error ? { error: msg.error.message ?? JSON.stringify(msg.error) } : { result: msg.result });
|
||||
}
|
||||
} catch { svc.logs.push(`[stdout] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); }
|
||||
});
|
||||
|
||||
const errRl = createInterface({ input: child.stderr });
|
||||
errRl.on("line", line => { svc.logs.push(`[stderr] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); });
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[runner] ${svc.name} exited code=${code} signal=${signal} restarts=${svc.restarts}`);
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "crashed" }); }
|
||||
svc.pending.clear(); svc.process = null; svc.pid = null;
|
||||
if (svc.status === "running" && svc.restarts < 5) {
|
||||
svc.restarts++;
|
||||
svc.status = "restarting";
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restarts);
|
||||
} else if (svc.status === "running") { svc.status = "crashed"; }
|
||||
});
|
||||
|
||||
child.on("error", err => { console.error(`[runner] ${svc.name} spawn error: ${err.message}`); svc.status = "failed"; });
|
||||
console.log(`[runner] spawned ${svc.name} pid=${child.pid} cmd=${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
// --- HTTP API ---
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", c => chunks.push(c));
|
||||
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const svcs = [];
|
||||
for (const [name, svc] of services) {
|
||||
svcs.push({ name, status: svc.status, pid: svc.pid, tools: svc.tools.length, restarts: svc.restarts });
|
||||
}
|
||||
return json(res, 200, { ok: true, services: svcs });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/list")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
if (name) {
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
return json(res, 200, { tools: svc.tools });
|
||||
}
|
||||
const all = {};
|
||||
for (const [n, s] of services) all[n] = s.tools;
|
||||
return json(res, 200, all);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/logs")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
const lines = parseInt(url.searchParams.get("lines") || "50", 10);
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
return json(res, 200, { lines: svc.logs.slice(-lines) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/load") {
|
||||
const body = await readBody(req);
|
||||
const { name, sourcePath, gitUrl, gitBranch, npxPackage, env: svcEnv, runtime: rt } = body;
|
||||
if (!name) return json(res, 400, { error: "name required" });
|
||||
|
||||
// Kill existing
|
||||
const existing = services.get(name);
|
||||
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
|
||||
|
||||
// Determine source path — git clone, npx, or pre-existing path
|
||||
let svcSourcePath = sourcePath;
|
||||
let svcRuntime = rt;
|
||||
|
||||
if (gitUrl) {
|
||||
// Git clone into runner's local storage
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
const { execSync } = await import("node:child_process");
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
try {
|
||||
// Clean existing clone
|
||||
execSync(`rm -rf ${svcSourcePath}/*`, { timeout: 10_000 });
|
||||
execSync(`git clone --depth 1 ${gitBranch ? `--branch ${gitBranch}` : ""} ${gitUrl} .`, { cwd: svcSourcePath, timeout: 120_000, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
console.log(`[runner] git clone complete: ${gitUrl} -> ${svcSourcePath}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `git clone failed: ${e.message}` });
|
||||
}
|
||||
} else if (npxPackage) {
|
||||
// npx-based: create a minimal package.json that depends on the package
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const pkg = { name: `mcp-${name}`, private: true, dependencies: { [npxPackage]: "*" } };
|
||||
writeFileSync(join(svcSourcePath, "package.json"), JSON.stringify(pkg, null, 2));
|
||||
svcRuntime = svcRuntime || "node";
|
||||
} else if (body.uvxPackage) {
|
||||
// uvx-based Python MCP: install via uv and find the entry point
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`uv venv --clear ${join(svcSourcePath, ".venv")}`, { timeout: 30_000, stdio: "pipe" });
|
||||
execSync(`uv pip install --python ${join(svcSourcePath, ".venv/bin/python")} "${body.uvxPackage}" "mcp[cli]"`, { timeout: 120_000, stdio: "pipe" });
|
||||
console.log(`[runner] uvx package installed: ${body.uvxPackage}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `uvx install failed: ${e.message}` });
|
||||
}
|
||||
// For Python MCPs: run via `python -m <module>` using the venv python.
|
||||
// The module name is derived from the package name: mcp-server-time → mcp_server_time
|
||||
const venvPython = join(svcSourcePath, ".venv/bin/python");
|
||||
const moduleName = body.uvxPackage.replace(/-/g, "_");
|
||||
svcRuntime = "python";
|
||||
// _pythonModule signals spawnService to use `python -m <module>` instead of binary
|
||||
const svc2 = { name, sourcePath: svcSourcePath, runtime: svcRuntime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "running", pending: new Map(), logs: [], restarts: 0, healthFailures: 0, _venvPython: venvPython, _pythonModule: moduleName };
|
||||
services.set(name, svc2);
|
||||
spawnService(svc2);
|
||||
// Python MCPs take longer to start — retry init with backoff
|
||||
let initErr = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1500 + attempt * 1000));
|
||||
try {
|
||||
svc2.tools = await initMcp(svc2);
|
||||
console.log(`[runner] ${name} ready (uvx), ${svc2.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc2.tools });
|
||||
} catch (e) { initErr = e; }
|
||||
}
|
||||
svc2.status = "failed"; svc2.logs.push(`MCP init failed after 3 attempts: ${initErr?.message}`);
|
||||
return json(res, 500, { error: initErr?.message, logs: svc2.logs.slice(-10) });
|
||||
} else if (!svcSourcePath) {
|
||||
return json(res, 400, { error: "one of sourcePath, gitUrl, or npxPackage required" });
|
||||
}
|
||||
|
||||
const runtime = svcRuntime || detectRuntime(svcSourcePath);
|
||||
const svc = { name, sourcePath: svcSourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
|
||||
services.set(name, svc);
|
||||
|
||||
// Install deps
|
||||
try { await installDeps(svcSourcePath, runtime); } catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message });
|
||||
}
|
||||
|
||||
// For npx packages: find the binary in node_modules/.bin
|
||||
if (npxPackage) {
|
||||
const binDir = join(svcSourcePath, "node_modules", ".bin");
|
||||
if (existsSync(binDir)) {
|
||||
const bins = readdirSync(binDir).filter(b => !["node-which", "which", "semver", "resolve"].includes(b));
|
||||
// Prefer binary matching the package name
|
||||
const pkgShort = npxPackage.split("/").pop().replace(/^@/, "");
|
||||
const match = bins.find(b => b === pkgShort || b.includes(pkgShort)) || bins[0];
|
||||
if (match) {
|
||||
svc._npxBin = join(binDir, match);
|
||||
console.log(`[runner] npx binary resolved: ${match}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn + MCP handshake
|
||||
spawnService(svc);
|
||||
await new Promise(r => setTimeout(r, 1000)); // npx packages may need more startup time
|
||||
try {
|
||||
svc.tools = await initMcp(svc);
|
||||
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc.tools });
|
||||
} catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`MCP init failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message, logs: svc.logs.slice(-10) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/call") {
|
||||
const body = await readBody(req);
|
||||
const { name, tool, args } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
if (svc.status !== "running") return json(res, 503, { error: `service is ${svc.status}` });
|
||||
const result = await sendMcpRequest(svc, "tools/call", { name: tool, arguments: args || {} });
|
||||
return json(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/unload") {
|
||||
const body = await readBody(req);
|
||||
const { name } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
svc.status = "stopped";
|
||||
if (svc.process) { svc.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 2000)); if (svc.process) svc.process.kill("SIGKILL"); }
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "unloaded" }); }
|
||||
services.delete(name);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
json(res, 404, { error: "not found" });
|
||||
} catch (e) {
|
||||
console.error("[runner] request error:", e);
|
||||
json(res, 500, { error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[runner] supervisor listening on :${PORT}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[runner] shutting down...");
|
||||
for (const [, svc] of services) { svc.status = "stopped"; svc.process?.kill("SIGTERM"); }
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
15
apps/telegram/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Telegram bridge for claudemesh
|
||||
# Node 22 runtime with tsx for TypeScript execution
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
20
apps/telegram/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@claudemesh/telegram",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"dev": "bun --hot src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.35.0",
|
||||
"ws": "^8.18.0",
|
||||
"libsodium": "^0.7.15",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/libsodium-wrappers": "^0.7.14"
|
||||
}
|
||||
}
|
||||
759
apps/telegram/src/index.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* Claudemesh ↔ Telegram Bridge
|
||||
*
|
||||
* Joins the mesh as a peer named "telegram-bridge", relays messages
|
||||
* between a Telegram chat and mesh peers.
|
||||
*
|
||||
* Telegram → Mesh:
|
||||
* "@Mou fix the bug" → send_message(to: "Mou", message: "fix the bug")
|
||||
* "/peers" → list_peers → reply with online list
|
||||
* "/broadcast hello" → send_message(to: "*", message: "hello")
|
||||
* "any text" → send_message(to: "*", message: text) (broadcast)
|
||||
*
|
||||
* Mesh → Telegram:
|
||||
* Any push message addressed to this peer → forward to Telegram chat
|
||||
*/
|
||||
|
||||
import { Bot, InputFile } from "grammy";
|
||||
import WebSocket from "ws";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// --- Config ---
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||
const ALLOWED_CHAT_IDS = (process.env.TELEGRAM_CHAT_IDS ?? "").split(",").filter(Boolean).map(Number);
|
||||
const CONFIG_DIR = process.env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const DISPLAY_NAME = process.env.BRIDGE_NAME ?? "telegram-bridge";
|
||||
|
||||
if (!BOT_TOKEN) {
|
||||
console.error("TELEGRAM_BOT_TOKEN required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Load mesh config ---
|
||||
interface JoinedMesh {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
pubkey: string;
|
||||
secretKey: string;
|
||||
brokerUrl: string;
|
||||
}
|
||||
|
||||
function loadMeshConfig(): JoinedMesh[] {
|
||||
// Support env-based config for Docker/VPS deployment
|
||||
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
|
||||
return [{
|
||||
meshId: process.env.MESH_ID,
|
||||
memberId: process.env.MESH_MEMBER_ID,
|
||||
slug: process.env.MESH_SLUG ?? "mesh",
|
||||
name: process.env.MESH_NAME ?? "mesh",
|
||||
pubkey: process.env.MESH_PUBKEY,
|
||||
secretKey: process.env.MESH_SECRET_KEY,
|
||||
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
}];
|
||||
}
|
||||
// Fall back to config file
|
||||
const path = join(CONFIG_DIR, "config.json");
|
||||
if (!existsSync(path)) {
|
||||
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return config.meshes ?? [];
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
let sodiumReady = false;
|
||||
|
||||
async function ensureSodium() {
|
||||
if (!sodiumReady) {
|
||||
await sodium.ready;
|
||||
sodiumReady = true;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
async function generateSessionKeypair() {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_sign_keypair();
|
||||
return {
|
||||
publicKey: s.to_hex(kp.publicKey),
|
||||
secretKey: s.to_hex(kp.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function signHello(meshId: string, memberId: string, pubkey: string, secretKeyHex: 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) };
|
||||
}
|
||||
|
||||
/** Decrypt a direct message envelope using crypto_box (X25519). */
|
||||
async function decryptDirect(
|
||||
nonce: string,
|
||||
ciphertext: string,
|
||||
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 nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
|
||||
const ciphertextBytes = s.from_base64(ciphertext, s.base64_variants.ORIGINAL);
|
||||
const plain = s.crypto_box_open_easy(ciphertextBytes, nonceBytes, senderPub, recipientSec);
|
||||
return s.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mesh WS Client (simplified) ---
|
||||
interface PeerInfo {
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
cwd?: string;
|
||||
groups?: string[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
class MeshBridge {
|
||||
private ws: WebSocket | null = null;
|
||||
private mesh: JoinedMesh;
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private onMessage: (from: string, text: string, priority: string) => void;
|
||||
private resolvers = new Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>();
|
||||
/** Map pubkey → {name, avatar}, populated from list_peers */
|
||||
private peerInfo = new Map<string, { name: string; avatar?: string }>();
|
||||
|
||||
constructor(mesh: JoinedMesh, onMessage: (from: string, text: string, priority: string) => void) {
|
||||
this.mesh = mesh;
|
||||
this.onMessage = onMessage;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const sessionKP = await generateSessionKeypair();
|
||||
this.sessionPubkey = sessionKP.publicKey;
|
||||
this.sessionSecretKey = sessionKP.secretKey;
|
||||
return this._connect();
|
||||
}
|
||||
|
||||
private _connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.mesh.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
const { timestamp, signature } = await signHello(
|
||||
this.mesh.meshId, this.mesh.memberId,
|
||||
this.mesh.pubkey, this.mesh.secretKey,
|
||||
);
|
||||
ws.send(JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.mesh.meshId,
|
||||
memberId: this.mesh.memberId,
|
||||
pubkey: this.mesh.pubkey,
|
||||
sessionPubkey: this.sessionPubkey,
|
||||
displayName: DISPLAY_NAME,
|
||||
sessionId: `telegram-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
hostname: require("os").hostname(),
|
||||
peerType: "bridge",
|
||||
channel: "telegram",
|
||||
timestamp,
|
||||
signature,
|
||||
}));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
const helloTimeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, 10_000);
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type !== "hello_ack" && msg.type !== "ack") {
|
||||
console.log(`[mesh] recv: ${msg.type}${msg.subtype ? '/' + msg.subtype : ''}${msg.event ? '/' + msg.event : ''}`);
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
clearTimeout(helloTimeout);
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
console.log(`[mesh] connected to ${this.mesh.slug} as ${DISPLAY_NAME}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push messages from peers
|
||||
if (msg.type === "push") {
|
||||
let text: string | null = null;
|
||||
const senderPubkey = msg.senderPubkey ?? msg.senderSessionPubkey;
|
||||
|
||||
// System messages (no encryption)
|
||||
if (msg.subtype === "system") {
|
||||
const event = msg.event ?? "";
|
||||
const data = msg.eventData ?? {};
|
||||
if (event === "peer_joined") text = `[joined] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_left") text = `[left] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_returned") text = `[returned] ${data.name ?? "peer"}`;
|
||||
else text = msg.plaintext ?? `[${event}]`;
|
||||
}
|
||||
// Encrypted direct message
|
||||
else if (senderPubkey && msg.nonce && msg.ciphertext) {
|
||||
// Try session key first, then mesh member key
|
||||
text = await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.sessionSecretKey!)
|
||||
?? await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.mesh.secretKey);
|
||||
if (!text) text = "[could not decrypt]";
|
||||
}
|
||||
// Plaintext fallback (broadcasts, legacy)
|
||||
else if (msg.plaintext) {
|
||||
text = msg.plaintext;
|
||||
}
|
||||
// Base64 ciphertext without nonce (legacy broadcast)
|
||||
else if (msg.ciphertext && !msg.nonce) {
|
||||
try { text = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); } catch { text = "[decode error]"; }
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const info = senderPubkey ? this.peerInfo.get(senderPubkey) : null;
|
||||
const fromName = info?.name ?? (senderPubkey?.slice(0, 12) ?? "system");
|
||||
const avatar = info?.avatar ?? "🤖";
|
||||
console.log(`[mesh] push from ${avatar} ${fromName}: ${text.slice(0, 80)}`);
|
||||
this.onMessage(`${avatar} ${fromName}`, text, msg.priority ?? "next");
|
||||
} else {
|
||||
console.log(`[mesh] push with no text. subtype=${msg.subtype}, hasSender=${!!senderPubkey}, hasNonce=${!!msg.nonce}, hasCipher=${!!msg.ciphertext}, hasPlain=${!!msg.plaintext}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve pending requests
|
||||
const reqId = msg._reqId;
|
||||
if (reqId && this.resolvers.has(reqId)) {
|
||||
const r = this.resolvers.get(reqId)!;
|
||||
clearTimeout(r.timer);
|
||||
this.resolvers.delete(reqId);
|
||||
r.resolve(msg);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.connected = false;
|
||||
this.ws = null;
|
||||
if (this.reconnectTimer) return;
|
||||
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
|
||||
this.reconnectAttempt++;
|
||||
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this._connect().catch(e => console.error("[mesh] reconnect failed:", e));
|
||||
}, delay);
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
console.error("[mesh] ws error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private makeReqId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
private request(msg: Record<string, unknown>, timeout = 10_000): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
const timer = setTimeout(() => {
|
||||
this.resolvers.delete(reqId);
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
this.resolvers.set(reqId, { resolve, timer });
|
||||
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(to: string, message: string, priority: string = "next"): Promise<boolean> {
|
||||
if (!this.ws || !this.connected) return false;
|
||||
|
||||
// For direct targets (pubkeys), use crypto_box encryption.
|
||||
// For broadcasts/groups, use base64-encoded plaintext (legacy format).
|
||||
let nonce = "";
|
||||
let ciphertext = "";
|
||||
const isDirect = /^[0-9a-f]{64}$/.test(to);
|
||||
if (isDirect) {
|
||||
const s = await ensureSodium();
|
||||
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(to));
|
||||
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(this.sessionSecretKey!));
|
||||
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||
const ciphertextBytes = s.crypto_box_easy(s.from_string(message), nonceBytes, recipientPub, senderSec);
|
||||
nonce = s.to_base64(nonceBytes, s.base64_variants.ORIGINAL);
|
||||
ciphertext = s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL);
|
||||
} else {
|
||||
// Broadcast/group: base64 plaintext (CLI decodes this when no nonce present)
|
||||
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
const id = this.makeReqId();
|
||||
console.log(`[mesh] sending to ${to.slice(0, 16)}…, encrypted=${isDirect}`);
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec: to,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find all peers matching a display name. */
|
||||
async findPeersByName(name: string): Promise<PeerInfo[]> {
|
||||
const peers = await this.listPeers();
|
||||
return peers.filter(p => p.displayName.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Upload a file to the mesh via broker HTTP. Returns file ID. */
|
||||
async uploadFile(data: Buffer, fileName: string, tags?: string[]): Promise<string | null> {
|
||||
const brokerHttp = this.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
try {
|
||||
const res = await fetch(`${brokerHttp}/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Mesh-Id": this.mesh.meshId,
|
||||
"X-Member-Id": this.mesh.memberId,
|
||||
"X-File-Name": fileName,
|
||||
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
|
||||
"X-Persistent": "true",
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
|
||||
if (!res.ok || !body.fileId) return null;
|
||||
return body.fileId;
|
||||
} catch (e) {
|
||||
console.error("[mesh] upload failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a download URL for a mesh file. */
|
||||
async getFileUrl(fileId: string): Promise<{ url: string; name: string } | null> {
|
||||
const resp = await this.request({ type: "get_file", fileId });
|
||||
if (!resp?.url) return null;
|
||||
return { url: resp.url, name: resp.name ?? "file" };
|
||||
}
|
||||
|
||||
async listPeers(): Promise<PeerInfo[]> {
|
||||
const resp = await this.request({ type: "list_peers" });
|
||||
if (!resp?.peers) return [];
|
||||
return resp.peers.map((p: any) => {
|
||||
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
|
||||
const avatar = p.profile?.avatar;
|
||||
// Cache pubkey → info for push message attribution
|
||||
const info = { name, avatar };
|
||||
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
|
||||
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
|
||||
return {
|
||||
displayName: name,
|
||||
pubkey: p.pubkey ?? "",
|
||||
status: p.status ?? "unknown",
|
||||
summary: p.summary,
|
||||
cwd: p.cwd,
|
||||
groups: p.groups?.map((g: any) => g.name) ?? [],
|
||||
avatar: avatar,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh peer name cache. Called periodically. */
|
||||
async refreshPeerNames(): Promise<void> {
|
||||
await this.listPeers();
|
||||
}
|
||||
|
||||
async setSummary(summary: string): Promise<void> {
|
||||
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve display name from peers ---
|
||||
async function resolveTarget(bridge: MeshBridge, name: string): Promise<string> {
|
||||
// If it starts with @, it's a group
|
||||
if (name.startsWith("@")) return name;
|
||||
// If *, broadcast
|
||||
if (name === "*") return "*";
|
||||
// Otherwise resolve as display name — the broker handles this via targetSpec
|
||||
return name;
|
||||
}
|
||||
|
||||
// --- Telegram Bot ---
|
||||
async function main() {
|
||||
const meshes = loadMeshConfig();
|
||||
if (meshes.length === 0) {
|
||||
console.error("No meshes joined — run 'claudemesh join' first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bot = new Bot(BOT_TOKEN);
|
||||
const bridges: MeshBridge[] = [];
|
||||
|
||||
// One bridge per mesh
|
||||
for (const mesh of meshes) {
|
||||
const bridge = new MeshBridge(mesh, (from, text, priority) => {
|
||||
// Forward mesh messages to all allowed Telegram chats
|
||||
const prefix = `[${mesh.slug}] ${from}`;
|
||||
const formatted = `💬 *${prefix}*\n${text}`;
|
||||
for (const chatId of ALLOWED_CHAT_IDS) {
|
||||
bot.api.sendMessage(chatId, formatted, { parse_mode: "Markdown" }).catch(e => {
|
||||
console.error(`[tg] failed to send to ${chatId}:`, e.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.connect();
|
||||
await bridge.setSummary("Telegram bridge — relays messages between Telegram and mesh peers");
|
||||
await bridge.refreshPeerNames();
|
||||
bridges.push(bridge);
|
||||
// Refresh peer names every 30s for display name resolution on pushes
|
||||
setInterval(() => bridge.refreshPeerNames().catch(() => {}), 30_000);
|
||||
} catch (e) {
|
||||
console.error(`[mesh] failed to connect to ${mesh.slug}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridges.length === 0) {
|
||||
console.error("Failed to connect to any mesh");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultBridge = bridges[0]!;
|
||||
|
||||
// --- Bot commands ---
|
||||
|
||||
bot.command("peers", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const peers = await defaultBridge.listPeers();
|
||||
if (peers.length === 0) {
|
||||
await ctx.reply("No peers online.");
|
||||
return;
|
||||
}
|
||||
const lines = peers.map(p => {
|
||||
const status = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
|
||||
const summary = p.summary ? ` — _${p.summary}_` : "";
|
||||
return `${status} *${p.displayName}*${summary}`;
|
||||
});
|
||||
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// Pending messages waiting for peer selection (chatId → {message, matches})
|
||||
const pendingDMs = new Map<number, { message: string; matches: PeerInfo[]; selected: Set<number> }>();
|
||||
|
||||
bot.command("dm", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
|
||||
// Find matching peers
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}" found.`);
|
||||
return;
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
// Single match — send directly
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
return;
|
||||
}
|
||||
// Multiple matches — show picker with individual + all option
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
const avatar = p.avatar ?? "🤖";
|
||||
return [{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
});
|
||||
|
||||
bot.command("broadcast", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const message = ctx.match;
|
||||
if (!message) {
|
||||
await ctx.reply("Usage: /broadcast <message>");
|
||||
return;
|
||||
}
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("group", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
const ok = await defaultBridge.sendMessage(target, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const meshStatus = bridges.map(b =>
|
||||
`${b.isConnected() ? "🟢" : "🔴"} Connected`
|
||||
).join("\n");
|
||||
await ctx.reply(`*Claudemesh Telegram Bridge*\n${meshStatus}`, { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// --- File: get a mesh file by ID ---
|
||||
bot.command("file", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const fileId = ctx.match?.trim();
|
||||
if (!fileId) {
|
||||
await ctx.reply("Usage: /file <file-id>");
|
||||
return;
|
||||
}
|
||||
const file = await defaultBridge.getFileUrl(fileId);
|
||||
if (!file) {
|
||||
await ctx.reply(`❌ File ${fileId} not found`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(file.url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!resp.ok) { await ctx.reply(`❌ Download failed (${resp.status})`); return; }
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
await ctx.replyWithDocument(new InputFile(buf, file.name));
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) {
|
||||
await ctx.reply("⛔ Not authorized. Add your chat ID to TELEGRAM_CHAT_IDS.");
|
||||
return;
|
||||
}
|
||||
await ctx.reply(
|
||||
"🔗 *Claudemesh Telegram Bridge*\n\n" +
|
||||
"Commands:\n" +
|
||||
"• /peers — List online peers\n" +
|
||||
"• /dm <name> <msg> — DM a specific peer\n" +
|
||||
"• /broadcast <msg> — Message all peers\n" +
|
||||
"• /group @name <msg> — Message a group\n" +
|
||||
"• /file <id> — Download a mesh file\n" +
|
||||
"• /status — Bridge connection status\n\n" +
|
||||
"Send a photo/document to share it with the mesh.\n" +
|
||||
"Or just type a message to broadcast it.",
|
||||
{ parse_mode: "Markdown" },
|
||||
);
|
||||
});
|
||||
|
||||
// Handle inline keyboard callbacks for peer selection
|
||||
bot.on("callback_query:data", async (ctx) => {
|
||||
const data = ctx.callbackQuery.data;
|
||||
const chatId = ctx.chat?.id;
|
||||
if (!chatId || !data.startsWith("dm:")) {
|
||||
await ctx.answerCallbackQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingDMs.get(chatId);
|
||||
if (!pending) {
|
||||
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "dm:all") {
|
||||
// Send to all matches
|
||||
let sent = 0;
|
||||
for (const p of pending.matches) {
|
||||
const ok = await defaultBridge.sendMessage(p.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
if (ok) sent++;
|
||||
}
|
||||
pendingDMs.delete(chatId);
|
||||
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
|
||||
await ctx.editMessageText(`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single selection: dm:0, dm:1, etc.
|
||||
const idx = parseInt(data.slice(3));
|
||||
const peer = pending.matches[idx];
|
||||
if (!peer) {
|
||||
await ctx.answerCallbackQuery({ text: "Invalid selection" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await defaultBridge.sendMessage(peer.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
pendingDMs.delete(chatId);
|
||||
const dir = peer.cwd?.split("/").pop() ?? "?";
|
||||
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
|
||||
await ctx.editMessageText(ok ? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})` : "❌ Not connected");
|
||||
});
|
||||
|
||||
// Handle photos from Telegram → share to mesh
|
||||
bot.on("message:photo", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const photo = ctx.message.photo.at(-1); // highest resolution
|
||||
if (!photo) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(photo.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = `telegram-photo-${Date.now()}.jpg`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "photo"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📷 Photo shared${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ Photo shared to mesh (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle documents from Telegram → share to mesh
|
||||
bot.on("message:document", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const doc = ctx.message.document;
|
||||
if (!doc) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(doc.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = doc.file_name ?? `telegram-file-${Date.now()}`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "document"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📎 File shared: ${name}${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ File shared to mesh: ${name} (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Default: any text without a command → broadcast
|
||||
bot.on("message:text", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.message.text;
|
||||
if (text.startsWith("/")) return; // Skip unknown commands
|
||||
|
||||
// Check for @mention pattern: "@PeerName message"
|
||||
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
|
||||
if (mentionMatch) {
|
||||
const target = mentionMatch[1]!;
|
||||
const message = mentionMatch[2]!;
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}"`);
|
||||
} else if (matches.length === 1) {
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
} else {
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
return [{ text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No mention → broadcast
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${text}`, "next");
|
||||
if (!ok) await ctx.reply("❌ Not connected to mesh");
|
||||
});
|
||||
|
||||
function isAllowed(chatId: number): boolean {
|
||||
// If no chat IDs configured, allow all (dev mode)
|
||||
if (ALLOWED_CHAT_IDS.length === 0) return true;
|
||||
return ALLOWED_CHAT_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
// Start bot
|
||||
console.log("[tg] starting bot...");
|
||||
bot.start({
|
||||
onStart: () => console.log("[tg] bot running"),
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
|
||||
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||
|
||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
|
||||
|
||||
##############################
|
||||
### CLI Sync config ###
|
||||
##############################
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
@@ -10,7 +10,11 @@ RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
||||
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# --ignore-scripts skips sherif postinstall linting (exits 1 on warnings)
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts && \
|
||||
node node_modules/esbuild/install.js && \
|
||||
node node_modules/sharp/install/check.js || npm run --prefix node_modules/sharp build 2>/dev/null; \
|
||||
true
|
||||
|
||||
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||
ENV NODE_ENV=production
|
||||
@@ -25,9 +29,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
|
||||
# TURBOPACK=0 forces webpack for production build — Payload CMS's
|
||||
# richtext-lexical CSS imports fail under Turbopack.
|
||||
ENV TURBOPACK=0
|
||||
# Node ESM loader that stubs .css imports during route collection.
|
||||
# Payload CMS deps import .css files that Node can't handle outside webpack.
|
||||
ENV NODE_OPTIONS="--import /app/apps/web/css-stub-loader.mjs"
|
||||
RUN npx turbo run build --filter=web...
|
||||
|
||||
# Stage 2: runtime — standalone output only
|
||||
|
||||
33
apps/web/css-stub-loader.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Node.js ESM custom loader — stubs static asset imports as empty modules.
|
||||
*
|
||||
* Next.js 16 does route collection in raw Node ESM (not webpack/turbopack).
|
||||
* Payload CMS deps import .css, .scss, .svg, and other assets that Node
|
||||
* can't handle. This loader intercepts those and returns empty modules.
|
||||
*
|
||||
* Usage: NODE_OPTIONS="--import ./apps/web/css-stub-loader.mjs"
|
||||
*/
|
||||
|
||||
import { register } from "node:module";
|
||||
|
||||
register(
|
||||
"data:text/javascript," +
|
||||
encodeURIComponent(`
|
||||
const STYLE_RE = /\\.(css|scss|sass|less|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|otf)$/;
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
if (STYLE_RE.test(specifier)) {
|
||||
return { url: 'data:text/javascript,export default {};', shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
|
||||
export function load(url, context, nextLoad) {
|
||||
if (STYLE_RE.test(url)) {
|
||||
return { format: 'module', source: 'export default {};', shortCircuit: true };
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
`),
|
||||
import.meta.url,
|
||||
);
|
||||
@@ -88,7 +88,11 @@ const config: NextConfig = {
|
||||
"@payloadcms/db-postgres",
|
||||
"@payloadcms/db-sqlite",
|
||||
"@payloadcms/richtext-lexical",
|
||||
"@payloadcms/next",
|
||||
"@payloadcms/ui",
|
||||
"react-image-crop",
|
||||
"sharp",
|
||||
"libsodium-wrappers",
|
||||
],
|
||||
turbopack: {
|
||||
rules: {
|
||||
@@ -99,6 +103,24 @@ const config: NextConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
// Webpack SVG loader (used when TURBOPACK=0 for production builds).
|
||||
// Exclude app/ dir SVGs (icon.svg, opengraph-image) — Next.js metadata
|
||||
// loader handles those. Only process package SVGs (flags, logos).
|
||||
webpack(config) {
|
||||
const existingSvgRule = config.module.rules.find(
|
||||
(rule: { test?: RegExp }) => rule.test?.test?.(".svg"),
|
||||
);
|
||||
if (existingSvgRule) {
|
||||
existingSvgRule.exclude = /packages\/ui\/.*\.svg$/;
|
||||
}
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
include: /packages\/ui\//,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
return config;
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "next dev",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
|
||||
BIN
apps/web/public/logo-wordmark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -153,14 +153,14 @@ export default function AboutPage() {
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
|
||||
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
LinkedIn
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:info@whyrating.com"
|
||||
href="mailto:alex@mourente.ai"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
|
||||
458
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Getting Started",
|
||||
description:
|
||||
"Install the CLI and launch your first peer session in two commands.",
|
||||
});
|
||||
|
||||
const STEP = ({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
cmd,
|
||||
note,
|
||||
}: {
|
||||
n: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
cmd?: string;
|
||||
note?: string;
|
||||
}) => (
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-4 flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)]/15 text-[11px] font-medium">
|
||||
{n}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{cmd && (
|
||||
<pre
|
||||
className="mt-4 overflow-x-auto rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-3 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{cmd}</code>
|
||||
</pre>
|
||||
)}
|
||||
{note && (
|
||||
<p
|
||||
className="mt-3 text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const VERIFY_CHECKS = [
|
||||
"Node.js >= 20 installed",
|
||||
"claude binary on PATH",
|
||||
"~/.claudemesh/config.json parses + chmod 0600",
|
||||
"Mesh keypairs valid",
|
||||
"Broker connectivity",
|
||||
];
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 md:px-12 md:py-24">
|
||||
<div
|
||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— getting started
|
||||
</div>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
From zero to meshed in two minutes
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 max-w-xl text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Install the CLI and launch. Two commands — join is built into launch.
|
||||
</p>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<div className="mt-14 mb-10">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Prerequisites
|
||||
</h2>
|
||||
<ul
|
||||
className="space-y-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">Node.js 20+</strong> —{" "}
|
||||
<Link
|
||||
href="https://nodejs.org"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
nodejs.org
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">Claude Code 2.0+</strong>{" "}
|
||||
—{" "}
|
||||
<Link
|
||||
href="https://claude.com/claude-code"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
claude.com/claude-code
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">An invite link</strong> —
|
||||
from a mesh owner, or{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
create your own mesh
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-6">
|
||||
<STEP
|
||||
n="1"
|
||||
title="Install the CLI"
|
||||
cmd="npm i -g claudemesh-cli"
|
||||
note="Requires Node.js 20+. Installs the claudemesh CLI globally."
|
||||
>
|
||||
<p>
|
||||
One command. If you get a permissions error, see{" "}
|
||||
<Link
|
||||
href="https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
npm docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</STEP>
|
||||
|
||||
<STEP
|
||||
n="2"
|
||||
title="Launch"
|
||||
cmd='claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...'
|
||||
note="--join enrolls you in the mesh (first time only). On subsequent launches, drop the --join flag."
|
||||
>
|
||||
<p>
|
||||
This does everything: verifies the invite, generates your ed25519
|
||||
keypair, enrolls with the broker, and spawns Claude Code with
|
||||
real-time peer messaging. Your keys are stored in{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
~/.claudemesh/config.json
|
||||
</code>{" "}
|
||||
(chmod 0600) — the broker never sees them.
|
||||
</p>
|
||||
</STEP>
|
||||
|
||||
<div
|
||||
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
next time, just:
|
||||
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
|
||||
claudemesh launch --name Alice
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`# Full example with all flags
|
||||
claudemesh launch \\
|
||||
--name Alice \\
|
||||
--join https://claudemesh.com/join/eyJ2IjoxLC... \\
|
||||
--role dev \\
|
||||
--groups "frontend:lead,reviewers" \\
|
||||
--message-mode push \\
|
||||
-y # skip permission confirmation`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Verify */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Verify your setup
|
||||
</h2>
|
||||
<p
|
||||
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Run the diagnostic check — it walks through every precondition and
|
||||
prints pass/fail with fix hints:
|
||||
</p>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`$ claudemesh doctor
|
||||
claudemesh doctor (v0.8.0)
|
||||
────────────────────────────────────────────────────────────
|
||||
✓ Node.js >= 20 (v22.15.0)
|
||||
✓ claude binary on PATH
|
||||
✓ ~/.claudemesh/config.json parses + chmod 0600
|
||||
✓ Mesh keypairs valid (1 mesh(es))
|
||||
✓ Broker connectivity (wss://ic.claudemesh.com/ws)
|
||||
|
||||
All checks passed.`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Invite a teammate */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Invite a teammate
|
||||
</h2>
|
||||
<p
|
||||
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Mesh owners generate invite links from the{" "}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
dashboard
|
||||
</Link>
|
||||
. Each link is a signed ed25519 token with a mesh ID, broker URL,
|
||||
expiry, and role (admin or member). Share via Slack, email, or
|
||||
paste in chat.
|
||||
</p>
|
||||
<p
|
||||
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
The recipient runs{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch --name Name --join <link>
|
||||
</code>{" "}
|
||||
— joins the mesh and launches in one step. No account creation
|
||||
needed. Identity is the ed25519 keypair.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite link formats */}
|
||||
<div className="mt-10">
|
||||
<h3
|
||||
className="mb-3 text-base font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Accepted invite formats
|
||||
</h3>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`# Join + launch in one step (recommended)
|
||||
claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
|
||||
# Or join separately first
|
||||
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
claudemesh launch --name Alice
|
||||
|
||||
# All invite formats work with both join and --join:
|
||||
# https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
# https://claudemesh.com/en/join/eyJ2IjoxLC...
|
||||
# ic://join/eyJ2IjoxLC...
|
||||
# eyJ2IjoxLC4uLg (raw token)`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Message modes */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Message modes
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
mode: "push",
|
||||
desc: "Real-time. Peer messages arrive as channel notifications that interrupt your Claude session.",
|
||||
when: "Default. Best for active collaboration.",
|
||||
},
|
||||
{
|
||||
mode: "inbox",
|
||||
desc: "Held until you check. You get a notification but messages queue until check_messages.",
|
||||
when: "Deep work. Check when ready.",
|
||||
},
|
||||
{
|
||||
mode: "off",
|
||||
desc: "No delivery. Tools still work — use check_messages to poll manually.",
|
||||
when: "Solo work on a shared mesh.",
|
||||
},
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.mode}
|
||||
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||
>
|
||||
<code
|
||||
className="mb-2 block text-sm font-medium text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
--message-mode {m.mode}
|
||||
</code>
|
||||
<p
|
||||
className="mb-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{m.desc}
|
||||
</p>
|
||||
<p
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.when}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* With vs without launch */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh launch</code> vs plain{" "}
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>
|
||||
</h2>
|
||||
<div className="grid gap-px overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-border)] md:grid-cols-2">
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||
<div
|
||||
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li>Real-time push messages from peers</li>
|
||||
<li>Native MCP entries for deployed mesh services</li>
|
||||
<li>Per-session ephemeral keypair</li>
|
||||
<li>Display name, groups, and roles</li>
|
||||
<li>Session config isolated in tmpdir</li>
|
||||
<li>MCP_TIMEOUT + output limits tuned for mesh</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||
<div
|
||||
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
plain claude
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li>All 43 MCP tools still work</li>
|
||||
<li>Messages are pull-only (check_messages)</li>
|
||||
<li>No real-time push delivery</li>
|
||||
<li>Uses member keypair (not ephemeral)</li>
|
||||
<li>No display name or group assignment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uninstall */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Uninstall
|
||||
</h2>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`claudemesh uninstall # remove MCP server, hooks, and allowedTools
|
||||
npm uninstall -g claudemesh-cli
|
||||
rm -rf ~/.claudemesh # delete config + keypairs (irreversible)`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-16 flex flex-col items-start gap-4 border-t border-[var(--cm-border)] pt-10">
|
||||
<p
|
||||
className="text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Need help? Run{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh doctor
|
||||
</code>{" "}
|
||||
to diagnose issues, or{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli/issues"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
open an issue on GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Create a mesh →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Hero } from "~/modules/marketing/home/hero";
|
||||
import { Surfaces } from "~/modules/marketing/home/surfaces";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
||||
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
||||
import { Timeline } from "~/modules/marketing/home/timeline";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { FAQ } from "~/modules/marketing/home/faq";
|
||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
||||
|
||||
// Revalidate the page every 60s so the mesh-stats counter stays fresh
|
||||
// without hammering the DB. The /api/public/stats endpoint has its own
|
||||
// 60s in-memory cache too.
|
||||
export const revalidate = 60;
|
||||
|
||||
const HomePage = () => {
|
||||
@@ -24,14 +17,10 @@ const HomePage = () => {
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<Hero />
|
||||
<Surfaces />
|
||||
<Pricing />
|
||||
<LaptopToLaptop />
|
||||
<Features />
|
||||
<MeetsYou />
|
||||
<WhatIsClaudemesh />
|
||||
<DemoDashboard />
|
||||
<BeyondTerminal />
|
||||
<Timeline />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<CallToAction />
|
||||
<MeshStats />
|
||||
|
||||
876
apps/web/src/app/[locale]/cli-auth/cli-auth-flow.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Mesh {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
myRole: "admin" | "member";
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string | null;
|
||||
port: string | null;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
const ease = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated mesh node background
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MeshBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Floating mesh nodes */}
|
||||
{[
|
||||
{ x: "12%", y: "18%", delay: 0, size: 3 },
|
||||
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
|
||||
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
|
||||
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
|
||||
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
|
||||
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
|
||||
].map((node, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[var(--cm-clay)]"
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: node.size,
|
||||
height: node.size,
|
||||
}}
|
||||
animate={{
|
||||
opacity: [0.15, 0.4, 0.15],
|
||||
scale: [1, 1.5, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
delay: node.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Connecting lines (SVG) */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
|
||||
<line
|
||||
x1="12%"
|
||||
y1="18%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="85%"
|
||||
y1="14%"
|
||||
x2="72%"
|
||||
y2="55%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="72%"
|
||||
y1="55%"
|
||||
x2="92%"
|
||||
y2="78%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="8%"
|
||||
y1="65%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal-style status indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
|
||||
const colors = {
|
||||
waiting: "bg-[var(--cm-clay)]",
|
||||
syncing: "bg-amber-400",
|
||||
done: "bg-emerald-400",
|
||||
error: "bg-red-400",
|
||||
};
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
|
||||
const [meshes, setMeshes] = useState<Mesh[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Create-mesh form state
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [slugDirty, setSlugDirty] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-slug from name
|
||||
useEffect(() => {
|
||||
if (!slugDirty && newName) {
|
||||
setNewSlug(slugify(newName));
|
||||
}
|
||||
}, [newName, slugDirty]);
|
||||
|
||||
// Fetch user meshes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await handle(api.my.meshes.$get, {
|
||||
schema: getMyMeshesResponseSchema,
|
||||
})({
|
||||
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
|
||||
});
|
||||
setMeshes(data);
|
||||
setSelected(new Set(data.map((m) => m.id)));
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to load your meshes.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Auto-focus name input when no meshes
|
||||
useEffect(() => {
|
||||
if (!loading && meshes.length === 0 && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [loading, meshes.length]);
|
||||
|
||||
const toggleMesh = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const status = token
|
||||
? redirected
|
||||
? "done"
|
||||
: "done"
|
||||
: syncing || creating
|
||||
? "syncing"
|
||||
: error
|
||||
? "error"
|
||||
: "waiting";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create mesh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const createRes = await fetch("/api/my/meshes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
slug: newSlug.trim(),
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
}),
|
||||
});
|
||||
const res = (await createRes.json()) as
|
||||
| { id: string; slug: string }
|
||||
| { error: string };
|
||||
if (!createRes.ok || "error" in res) {
|
||||
setCreateError("error" in res ? res.error : "Failed to create mesh.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
await doSync(
|
||||
[{ id: res.id, slug: res.slug, role: "admin" as const }],
|
||||
"create",
|
||||
{ name: newName.trim(), slug: newSlug.trim() },
|
||||
);
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const doSync = async (
|
||||
meshList: Array<{ id: string; slug: string; role: string }>,
|
||||
action: "sync" | "create" = "sync",
|
||||
newMesh?: { name: string; slug: string },
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-sync-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ meshes: meshList, action, newMesh }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to generate token.");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
const jwt = data.token as string;
|
||||
setToken(jwt);
|
||||
if (port) {
|
||||
setRedirected(true);
|
||||
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
const selectedMeshes = meshes
|
||||
.filter((m) => selected.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.isOwner ? "admin" : m.myRole,
|
||||
}));
|
||||
if (selectedMeshes.length === 0) {
|
||||
setError("Select at least one mesh to sync.");
|
||||
return;
|
||||
}
|
||||
doSync(selectedMeshes, "sync");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="claudemesh home"
|
||||
className="group flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[17px] font-medium tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<StatusPulse status={status} />
|
||||
<span>
|
||||
{status === "waiting" && "awaiting sync"}
|
||||
{status === "syncing" && "generating token..."}
|
||||
{status === "done" && "synced"}
|
||||
{status === "error" && "error"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
||||
<MeshBackdrop />
|
||||
|
||||
{/* Section tag */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— cli sync
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.08 }}
|
||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Sync with{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.16 }}
|
||||
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Link your terminal session to your account and choose which meshes to
|
||||
sync.
|
||||
</motion.p>
|
||||
|
||||
{/* Pairing code */}
|
||||
<AnimatePresence>
|
||||
{code && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.5, ease, delay: 0.24 }}
|
||||
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
|
||||
>
|
||||
{/* Terminal-style header bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
</div>
|
||||
<span
|
||||
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
pairing verification
|
||||
</span>
|
||||
</div>
|
||||
{/* Code display */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
code:
|
||||
</span>
|
||||
<motion.span
|
||||
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
{code.split("").map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Confirm this matches the code shown in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-10 space-y-3"
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
|
||||
>
|
||||
<span className="mt-0.5 text-red-400">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token result */}
|
||||
<AnimatePresence>
|
||||
{token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
|
||||
{/* Success header */}
|
||||
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-emerald-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-sm font-medium text-emerald-400"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Token body */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-5">
|
||||
<p
|
||||
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected
|
||||
? "If your terminal didn\u2019t pick up the token, copy it manually:"
|
||||
: "Paste this token in your terminal when prompted:"}
|
||||
</p>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
onClick={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</div>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
) : (
|
||||
"Copy"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mesh list */}
|
||||
{!loading && !token && meshes.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<h2
|
||||
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your meshes
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{meshes.map((m, i) => (
|
||||
<motion.label
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
|
||||
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
|
||||
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{selected.has(m.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(m.id)}
|
||||
onChange={() => toggleMesh(m.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-[var(--cm-fg)]">
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{m.memberCount}{" "}
|
||||
{m.memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.isOwner ? "owner" : m.myRole}
|
||||
</span>
|
||||
</motion.label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSync}
|
||||
disabled={syncing || selected.size === 0}
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{selected.size} of {meshes.length} selected
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No meshes — create form */}
|
||||
{!loading && !token && meshes.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
|
||||
<h2
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Create your first mesh
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh is the space where your Claude Code sessions talk to each
|
||||
other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-name"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
id="mesh-name"
|
||||
type="text"
|
||||
placeholder="Platform team"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-slug"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="mesh-slug"
|
||||
type="text"
|
||||
placeholder="platform-team"
|
||||
value={newSlug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true);
|
||||
setNewSlug(e.target.value);
|
||||
}}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
lowercase · digits · hyphens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{createError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{createError}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create & sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer security note */}
|
||||
<AnimatePresence>
|
||||
{!token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
The sync token is valid for 15 minutes and can only be used once.
|
||||
Your ed25519 keys stay on your machine — the broker only sees
|
||||
ciphertext.
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/app/[locale]/cli-auth/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
import { CliAuthFlow } from "./cli-auth-flow";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Sync with CLI",
|
||||
description: "Link your claudemesh CLI to your account.",
|
||||
});
|
||||
|
||||
export default async function CliAuthPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ code?: string; port?: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
|
||||
if (!user) {
|
||||
const sp = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
if (sp.code) qs.set("code", sp.code);
|
||||
if (sp.port) qs.set("port", sp.port);
|
||||
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
|
||||
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
|
||||
const { code, port } = await searchParams;
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<CliAuthFlow
|
||||
code={code ?? null}
|
||||
port={port ?? null}
|
||||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
DashboardHeaderTitle,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||
import { ResourcePanel } from "~/modules/mesh/resource-panel";
|
||||
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Live mesh",
|
||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<LiveStreamPanel meshId={id} />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<PeerGraphPanel meshId={id} />
|
||||
<LiveStreamPanel meshId={id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<StateTimelinePanel meshId={id} />
|
||||
<ResourcePanel meshId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
130
apps/web/src/app/api/cli-sync-token/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT signing (HS256 via Web Crypto — no external deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64UrlEncode(input: string | ArrayBuffer): string {
|
||||
const str =
|
||||
typeof input === "string"
|
||||
? btoa(input)
|
||||
: btoa(String.fromCharCode(...new Uint8Array(input)));
|
||||
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function signJwt(
|
||||
payload: Record<string, unknown>,
|
||||
secret: string,
|
||||
): Promise<string> {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
encoder.encode(`${headerB64}.${payloadB64}`),
|
||||
);
|
||||
|
||||
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler — POST /api/cli-sync-token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SyncTokenBody {
|
||||
meshes: Array<{ id: string; slug: string; role: string }>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1. Check auth
|
||||
const reqHeaders = new Headers(await headers());
|
||||
reqHeaders.set("x-client-platform", "web-server");
|
||||
|
||||
const session = await auth.api.getSession({ headers: reqHeaders });
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parse body
|
||||
let body: SyncTokenBody;
|
||||
try {
|
||||
body = (await request.json()) as SyncTokenBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { meshes, action, newMesh } = body;
|
||||
|
||||
if (!Array.isArray(meshes)) {
|
||||
return NextResponse.json(
|
||||
{ error: "meshes must be an array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "sync" && action !== "create") {
|
||||
return NextResponse.json(
|
||||
{ error: 'action must be "sync" or "create"' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: "newMesh.name and newMesh.slug are required for create action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validate meshes belong to user — fetch user's meshes via internal API
|
||||
// For now we trust the dashboard-authenticated user's selection since
|
||||
// the broker will independently verify membership when the CLI connects.
|
||||
// A full server-side ownership check can be added later.
|
||||
|
||||
// 4. Get secret
|
||||
const secret = process.env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "CLI_SYNC_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Build and sign JWT
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
meshes: meshes.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.role,
|
||||
})),
|
||||
action,
|
||||
...(action === "create" && newMesh ? { newMesh } : {}),
|
||||
jti: crypto.randomUUID(),
|
||||
iat: now,
|
||||
exp: now + 15 * 60, // 15 minutes
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret);
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 211 B |
7
apps/web/src/app/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="4" r="2" fill="#d97757"/>
|
||||
<circle cx="4" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="20" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="12" cy="20" r="2" fill="#d97757"/>
|
||||
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* GET /install — serves a shell installer for claudemesh-cli.
|
||||
* GET /install — shell installer for claudemesh-cli.
|
||||
*
|
||||
* Intended to be piped into bash:
|
||||
* curl -fsSL https://claudemesh.com/install | bash
|
||||
* curl -fsSL https://claudemesh.com/install | bash
|
||||
*
|
||||
* The script is kept short + auditable. It does not try to install
|
||||
* Node for the user — it checks for a compatible Node + npm and
|
||||
* directs them to install Node themselves if missing. Running `bash`
|
||||
* against a domain you do not fully trust is always a risk; publishing
|
||||
* the script this way (rather than obfuscating it behind a binary
|
||||
* blob) lets security-conscious users inspect before executing.
|
||||
* Tracks each fetch server-side (PostHog server event + console log).
|
||||
* curl doesn't execute JS, so client-side analytics can't track this.
|
||||
*/
|
||||
|
||||
import { headers } from "next/headers";
|
||||
|
||||
// In-memory counter (resets on deploy — good enough for a signal).
|
||||
// For persistent tracking, write to DB or use PostHog server SDK.
|
||||
let installFetches = 0;
|
||||
|
||||
const SCRIPT = `#!/usr/bin/env bash
|
||||
# claudemesh-cli installer
|
||||
# Source: https://claudemesh.com/install
|
||||
@@ -88,7 +89,41 @@ say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
|
||||
say ""
|
||||
`;
|
||||
|
||||
export function GET(): Response {
|
||||
export async function GET(): Promise<Response> {
|
||||
installFetches++;
|
||||
|
||||
// Log server-side for monitoring
|
||||
const h = await headers();
|
||||
const ua = h.get("user-agent") ?? "unknown";
|
||||
const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown";
|
||||
const referer = h.get("referer") ?? "direct";
|
||||
|
||||
console.log(
|
||||
`[install] #${installFetches} | ip=${ip} | ua=${ua.slice(0, 80)} | ref=${referer}`,
|
||||
);
|
||||
|
||||
// PostHog server-side event (if configured)
|
||||
try {
|
||||
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
if (posthogKey && posthogHost) {
|
||||
fetch(`${posthogHost}/capture/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: posthogKey,
|
||||
event: "install_script_fetched",
|
||||
distinct_id: ip,
|
||||
properties: {
|
||||
user_agent: ua,
|
||||
referer,
|
||||
install_count: installFetches,
|
||||
},
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return new Response(SCRIPT, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
||||
},
|
||||
},
|
||||
marketing: {
|
||||
gettingStarted: "/getting-started",
|
||||
pricing: "/pricing",
|
||||
contact: "/contact",
|
||||
blog: {
|
||||
@@ -85,6 +86,7 @@ const pathsConfig = {
|
||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||
error: `${AUTH_PREFIX}/error`,
|
||||
},
|
||||
cliAuth: "/cli-auth",
|
||||
dashboard: {
|
||||
user: {
|
||||
index: DASHBOARD_PREFIX,
|
||||
|
||||
@@ -5,8 +5,9 @@ interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --join ${token}`;
|
||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||
const INSTALL_CMD = "npx claudemesh@latest init";
|
||||
const INSTALL_CMD = "npm i -g claudemesh-cli";
|
||||
|
||||
export const InstallToggle = ({ token }: Props) => {
|
||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||
@@ -60,7 +61,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
}
|
||||
|
||||
if (hasCli === "yes") {
|
||||
const cmd = JOIN_CMD(token);
|
||||
const cmd = LAUNCH_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
@@ -68,7 +69,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
run this in your terminal
|
||||
join + launch in one step
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -96,7 +97,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const joinCmd = JOIN_CMD(token);
|
||||
const launchCmd = LAUNCH_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ol className="space-y-3">
|
||||
@@ -106,7 +107,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install + init
|
||||
install the CLI
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -127,8 +128,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Generates your ed25519 keypair locally and wires claudemesh into
|
||||
your Claude Code config. You own the keys.
|
||||
Requires Node.js 20+.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
@@ -137,38 +137,28 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||
join the mesh
|
||||
join + launch
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{joinCmd}
|
||||
{launchCmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(joinCmd, "join")}
|
||||
onClick={() => copy(launchCmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
||||
verify
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code session will announce itself to the mesh. Other
|
||||
peers see you appear as a green dot in their dashboard.
|
||||
Joins the mesh and launches Claude Code in one step.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -33,7 +33,8 @@ export const CallToAction = () => {
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Anthropic built Claude Code per developer. The next unlock is
|
||||
between developers. Build the layer with us.
|
||||
between developers. 43 tools, five databases, E2E encryption —
|
||||
open-source and ready now.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
|
||||
@@ -133,10 +133,10 @@ export const DemoDashboard = () => {
|
||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Real conversation between peers. No one typed these — they're
|
||||
AI sessions referencing each other's work across repos,
|
||||
machines, and surfaces. Hover any message to see what the broker
|
||||
sees.
|
||||
Real conversation between peers. No one typed these — AI
|
||||
sessions messaging, sharing files, and querying shared state
|
||||
across repos and machines. Hover any message to see what the
|
||||
broker sees: ciphertext only.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const ITEMS = [
|
||||
},
|
||||
{
|
||||
q: "How do I get started?",
|
||||
a: "One command: `curl -fsSL claudemesh.com/install | bash`. The script checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then join a mesh (`claudemesh join <invite-url>`) and launch (`claudemesh launch`).",
|
||||
a: "Three commands. First: `npm i -g claudemesh-cli` — installs the CLI from npm (requires Node >= 20). Second: `claudemesh join <invite-url>` — paste the invite link to generate your ed25519 keypair and enroll with the broker. Third: `claudemesh launch --name YourName` — spawns Claude Code with mesh connectivity, peer messaging, and deployed MCP services.",
|
||||
},
|
||||
{
|
||||
q: "Does claudemesh send my code or prompts to the cloud?",
|
||||
@@ -33,7 +33,11 @@ const ITEMS = [
|
||||
},
|
||||
{
|
||||
q: "How is this different from MCP?",
|
||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — so from the agent's point of view, other peers just look like callable tools (send_message, list_peers). It composes on top of MCP; it doesn't replace it.",
|
||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — 43 tools that let peers message, share files, query databases, search vectors, and build graphs together. From the agent's view, other peers look like callable tools. It composes on top of MCP; it doesn't replace it.",
|
||||
},
|
||||
{
|
||||
q: "What persistence backends does the mesh include?",
|
||||
a: "Five. Key-value shared state (instant push on change). Full-text searchable memory (survives across sessions). Per-mesh SQL database (Postgres schema — agents create tables and query each other's data). Vector search (Qdrant — semantic similarity over stored embeddings). Graph database (Neo4j — Cypher queries for relationship modeling). Plus MinIO for E2E encrypted file storage.",
|
||||
},
|
||||
{
|
||||
q: "What stops a malicious peer in my mesh?",
|
||||
|
||||
@@ -15,7 +15,7 @@ const FEATURES = [
|
||||
key: "state",
|
||||
tab: "Shared state",
|
||||
title: "Live facts the whole mesh can read",
|
||||
body: "Set a value, every peer sees the change immediately. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
|
||||
body: "Set a value, every peer sees the change instantly. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
|
||||
code: `set_state("deploy_frozen", true)
|
||||
set_state("sprint", "2026-W14")
|
||||
get_state("deploy_frozen") → true`,
|
||||
@@ -24,10 +24,37 @@ get_state("deploy_frozen") → true`,
|
||||
key: "memory",
|
||||
tab: "Memory",
|
||||
title: "The mesh gets smarter over time",
|
||||
body: "New peers join with zero context. Memory stores institutional knowledge — decisions, incidents, lessons. Full-text searchable. Survives across sessions. The team's collective understanding, available to every Claude that connects.",
|
||||
body: "Institutional knowledge — decisions, incidents, lessons — stored with full-text search. Survives across sessions. New peers join and recall what the team already learned.",
|
||||
code: `remember("Payments API rate-limits at 100 req/s
|
||||
after March incident", tags: ["payments"])
|
||||
recall("rate limit") → ranked results`,
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
tab: "Files",
|
||||
title: "Share artifacts, not copy-paste",
|
||||
body: "Upload a config, a migration script, a test fixture. Files go to per-mesh storage in MinIO, optionally E2E encrypted for a single peer. Grant access later without re-uploading. The mesh tracks who downloaded what.",
|
||||
code: `share_file(path: "./schema.sql", tags: ["migration"])
|
||||
share_file(path: "./creds.json", to: "jordan")
|
||||
grant_file_access(fileId: "abc", to: "sam")`,
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
tab: "Database",
|
||||
title: "A shared SQL database per mesh",
|
||||
body: "Peers create tables, insert rows, and query each other's data — all inside an isolated Postgres schema. One agent tracks bugs, another queries the list. Structured data exchange without file serialization.",
|
||||
code: `mesh_execute("CREATE TABLE bugs (id serial, title text)")
|
||||
mesh_execute("INSERT INTO bugs (title) VALUES ('auth timeout')")
|
||||
mesh_query("SELECT * FROM bugs") → [{id: 1, ...}]`,
|
||||
},
|
||||
{
|
||||
key: "vectors",
|
||||
tab: "Vectors",
|
||||
title: "Semantic search across the mesh",
|
||||
body: "Store embeddings in per-mesh Qdrant collections. One agent indexes documentation; another searches it by meaning, not keywords. The mesh builds a shared knowledge base automatically.",
|
||||
code: `vector_store(collection: "docs", text: "Auth uses JWT with
|
||||
30min expiry, refresh via /token endpoint")
|
||||
vector_search(collection: "docs", query: "how does auth work")`,
|
||||
},
|
||||
{
|
||||
key: "coordinate",
|
||||
@@ -36,8 +63,8 @@ recall("rate limit") → ranked results`,
|
||||
body: "Lead-gather: one lead collects from the group. Chain review: work passes through each member. Delegation: lead assigns subtasks. Voting: members set state, lead tallies. Flood: everyone responds. All through system prompts — no broker code.",
|
||||
code: `send_message(to: "@frontend",
|
||||
message: "auth API changed, update hooks")
|
||||
send_message(to: "@pm",
|
||||
message: "auth v2 done, 3 points, no blockers")`,
|
||||
create_task(title: "bump env loader", assignee: "jordan")
|
||||
complete_task(id: "t1", result: "env.ts updated, PR #42")`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,7 +90,7 @@ export const Features = () => {
|
||||
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
30+ MCP tools. Groups, state, memory, messaging — all shipped.
|
||||
43 MCP tools. Groups, state, memory, files, databases, vectors, streams — all shipped.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const LOGOS = [
|
||||
"Claude Code",
|
||||
"MCP",
|
||||
"libsodium",
|
||||
"Bun",
|
||||
"TypeScript",
|
||||
"MIT",
|
||||
];
|
||||
|
||||
export const Hero = () => {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
@@ -26,42 +17,40 @@ export const Hero = () => {
|
||||
<SectionIcon glyph="mesh" />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1} className="mb-5">
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— meshing
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={2}>
|
||||
<Reveal delay={1}>
|
||||
<h1
|
||||
className="max-w-5xl text-center text-[clamp(2.75rem,7vw,5.75rem)] font-medium leading-[1.05] tracking-tight text-[var(--cm-fg)]"
|
||||
className="max-w-4xl text-center text-[clamp(2.75rem,7vw,5.25rem)] font-medium leading-[1.08] tracking-tight text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Built for{" "}
|
||||
<span className="inline-flex items-baseline gap-2 text-[var(--cm-clay)]">
|
||||
<span className="italic">{"<"}</span>
|
||||
<span className="italic">swarms</span>
|
||||
<span className="italic">{">"}</span>
|
||||
Your Claude Code sessions{" "}
|
||||
<span className="text-[var(--cm-clay)]">work alone.</span>
|
||||
<br />
|
||||
<span className="text-[var(--cm-fg-secondary)]">
|
||||
claudemesh connects them.
|
||||
</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
|
||||
className="mx-auto mt-8 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code sessions form a team. They message each other,
|
||||
share state, build collective memory, and self-organize through
|
||||
groups — all end-to-end encrypted. One command to launch. The broker
|
||||
routes ciphertext; it never reads your messages.
|
||||
<span className="block pt-2 text-[var(--cm-clay)]">
|
||||
Open-source CLI. Free during public beta.
|
||||
</span>
|
||||
Right now you relay AI insights through Slack threads. You re-explain
|
||||
context every time you switch machines. Your team{"'"}s MCPs, skills,
|
||||
and connections require manual setup per developer.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<p
|
||||
className="mx-auto mt-4 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg)] md:text-xl"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh gives every Claude Code session a shared wire. Each Claude
|
||||
keeps its own repo and perspective. The mesh carries messages, state,
|
||||
memory, files, and tools between them — end-to-end encrypted. The
|
||||
broker routes ciphertext. It never reads your messages.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
@@ -87,34 +76,58 @@ export const Hero = () => {
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={6}>
|
||||
<p
|
||||
className="mt-6 text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Or{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli#readme"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||
>
|
||||
read the documentation
|
||||
</Link>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={8}>
|
||||
<div className="mt-20 flex flex-wrap items-center justify-center gap-x-12 gap-y-6 opacity-70">
|
||||
{LOGOS.map((logo) => (
|
||||
{/* Pain points — three concrete scenarios */}
|
||||
<Reveal delay={5}>
|
||||
<div className="mx-auto mt-20 grid max-w-4xl gap-6 md:grid-cols-3">
|
||||
{([
|
||||
{
|
||||
label: "Context dies",
|
||||
body: "Close the terminal. Everything your Claude learned disappears. Open a new session — start from zero.",
|
||||
},
|
||||
{
|
||||
label: "Teams relay by hand",
|
||||
body: "Your backend Claude finds a bug. You copy the insight into Slack. The frontend dev pastes it into their Claude. Three tools for one thought.",
|
||||
},
|
||||
{
|
||||
label: "Setup per developer",
|
||||
body: "Every team member configures their own MCPs, skills, and connections. No shared standard. No shared context.",
|
||||
},
|
||||
] as const).map((pain) => (
|
||||
<div
|
||||
key={logo}
|
||||
className="text-xl font-medium tracking-tight text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
key={pain.label}
|
||||
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6"
|
||||
>
|
||||
{logo}
|
||||
<div
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{pain.label}
|
||||
</div>
|
||||
<p
|
||||
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{pain.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={6}>
|
||||
<p
|
||||
className="mt-12 text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Open-source CLI · Free during public beta ·{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||
>
|
||||
View source
|
||||
</Link>
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
350
apps/web/src/modules/marketing/home/mesh-vs-mcp.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const ROWS: Array<{
|
||||
dimension: string;
|
||||
mcp: string;
|
||||
mesh: string;
|
||||
}> = [
|
||||
{
|
||||
dimension: "What it connects",
|
||||
mcp: "One Claude session to external tools and services",
|
||||
mesh: "Many Claude sessions to each other",
|
||||
},
|
||||
{
|
||||
dimension: "Direction",
|
||||
mcp: "Vertical — agent calls down into tools",
|
||||
mesh: "Horizontal — agents talk across to peers",
|
||||
},
|
||||
{
|
||||
dimension: "Identity",
|
||||
mcp: "None — the tool doesn't know who called it",
|
||||
mesh: "ed25519 keypair per session, signed handshake, display names and roles",
|
||||
},
|
||||
{
|
||||
dimension: "Encryption",
|
||||
mcp: "Transport only (stdio or HTTP)",
|
||||
mesh: "End-to-end — libsodium crypto_box per message, secretbox per file",
|
||||
},
|
||||
{
|
||||
dimension: "State",
|
||||
mcp: "Stateless — each call starts fresh",
|
||||
mesh: "Shared KV state, full-text memory, SQL database, vector search, graph DB",
|
||||
},
|
||||
{
|
||||
dimension: "Presence",
|
||||
mcp: "None — no concept of online/offline",
|
||||
mesh: "Automatic — hook-driven status (idle, working, dnd), priority-gated delivery",
|
||||
},
|
||||
{
|
||||
dimension: "Scope",
|
||||
mcp: "One process, one machine",
|
||||
mesh: "Any number of machines, offices, continents",
|
||||
},
|
||||
{
|
||||
dimension: "Relationship",
|
||||
mcp: "Foundation — claudemesh ships as an MCP server",
|
||||
mesh: "Builds on MCP — from the agent's view, peers are just 43 callable tools",
|
||||
},
|
||||
];
|
||||
|
||||
export const MeshVsMcp = () => {
|
||||
return (
|
||||
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-24 md:px-12 md:py-32">
|
||||
<div className="mx-auto max-w-[var(--cm-max-w)]">
|
||||
<Reveal className="mb-6 flex justify-center">
|
||||
<SectionIcon glyph="grid" />
|
||||
</Reveal>
|
||||
<Reveal delay={1}>
|
||||
<div
|
||||
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— mesh vs mcp
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={2}>
|
||||
<h2
|
||||
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
MCP connects Claude to tools.{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">
|
||||
claudemesh connects Claudes to each other.
|
||||
</span>
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
<p
|
||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
They are not alternatives — claudemesh ships as an MCP server.
|
||||
From the agent's view, other peers are 43 callable tools. MCP
|
||||
is the transport. The mesh is the network.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Diagram */}
|
||||
<Reveal delay={4}>
|
||||
<div className="mx-auto mt-14 grid max-w-4xl gap-6 md:grid-cols-2">
|
||||
{/* MCP diagram */}
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
MCP alone
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 300 200"
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="MCP: one Claude session connected vertically to multiple tools"
|
||||
>
|
||||
{/* Agent */}
|
||||
<rect
|
||||
x="100"
|
||||
y="20"
|
||||
width="100"
|
||||
height="40"
|
||||
rx="4"
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke="var(--cm-fg-tertiary)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x="150"
|
||||
y="44"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="12"
|
||||
fontFamily="var(--cm-font-sans)"
|
||||
fontWeight="500"
|
||||
>
|
||||
Claude
|
||||
</text>
|
||||
{/* Lines down */}
|
||||
{[50, 150, 250].map((tx, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1="150"
|
||||
y1="60"
|
||||
x2={tx}
|
||||
y2="130"
|
||||
stroke="var(--cm-fg-tertiary)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 3"
|
||||
opacity="0.5"
|
||||
/>
|
||||
))}
|
||||
{/* Tools */}
|
||||
{[
|
||||
{ x: 50, label: "GitHub" },
|
||||
{ x: 150, label: "Postgres" },
|
||||
{ x: 250, label: "Slack" },
|
||||
].map((tool) => (
|
||||
<g key={tool.label}>
|
||||
<rect
|
||||
x={tool.x - 40}
|
||||
y="130"
|
||||
width="80"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="var(--cm-bg)"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={tool.x}
|
||||
y="150"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="11"
|
||||
fontFamily="var(--cm-font-mono)"
|
||||
>
|
||||
{tool.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Arrow label */}
|
||||
<text
|
||||
x="90"
|
||||
y="100"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="9"
|
||||
fontFamily="var(--cm-font-mono)"
|
||||
letterSpacing="0.08em"
|
||||
>
|
||||
CALLS ↓
|
||||
</text>
|
||||
</svg>
|
||||
<p
|
||||
className="mt-3 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
one agent, many tools, one machine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mesh diagram */}
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
MCP + claudemesh
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 300 200"
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="claudemesh: multiple Claude sessions connected horizontally through a broker"
|
||||
>
|
||||
{/* Agents */}
|
||||
{[
|
||||
{ x: 50, y: 30, label: "Alice" },
|
||||
{ x: 250, y: 30, label: "Bob" },
|
||||
{ x: 50, y: 150, label: "Jordan" },
|
||||
{ x: 250, y: 150, label: "Mo" },
|
||||
].map((agent) => (
|
||||
<g key={agent.label}>
|
||||
<line
|
||||
x1={agent.x}
|
||||
y1={agent.y + 16}
|
||||
x2="150"
|
||||
y2="100"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 3"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x={agent.x - 35}
|
||||
y={agent.y}
|
||||
width="70"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.5"
|
||||
/>
|
||||
<text
|
||||
x={agent.x}
|
||||
y={agent.y + 20}
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="11"
|
||||
fontFamily="var(--cm-font-sans)"
|
||||
fontWeight="500"
|
||||
>
|
||||
{agent.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Broker */}
|
||||
<rect
|
||||
x="110"
|
||||
y="80"
|
||||
width="80"
|
||||
height="40"
|
||||
rx="4"
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<text
|
||||
x="150"
|
||||
y="100"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-clay)"
|
||||
fontSize="11"
|
||||
fontFamily="var(--cm-font-sans)"
|
||||
fontWeight="500"
|
||||
>
|
||||
broker
|
||||
</text>
|
||||
<text
|
||||
x="150"
|
||||
y="113"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="8"
|
||||
fontFamily="var(--cm-font-mono)"
|
||||
letterSpacing="0.08em"
|
||||
>
|
||||
ciphertext only
|
||||
</text>
|
||||
</svg>
|
||||
<p
|
||||
className="mt-3 text-center text-[12px] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
many agents, peer-to-peer, any machine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Comparison table */}
|
||||
<Reveal delay={5}>
|
||||
<div className="mx-auto mt-14 max-w-4xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)]">
|
||||
{/* header row */}
|
||||
<div
|
||||
className="grid grid-cols-[1fr_1fr_1fr] border-b border-[var(--cm-border)] bg-[var(--cm-bg)] text-[10px] uppercase tracking-[0.18em]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="p-4 text-[var(--cm-fg-tertiary)]" />
|
||||
<div className="border-l border-[var(--cm-border)] p-4 text-[var(--cm-fg-tertiary)]">
|
||||
MCP
|
||||
</div>
|
||||
<div className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[var(--cm-clay)]">
|
||||
claudemesh
|
||||
</div>
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{ROWS.map((row, i) => (
|
||||
<div
|
||||
key={row.dimension}
|
||||
className={
|
||||
"grid grid-cols-[1fr_1fr_1fr] " +
|
||||
(i < ROWS.length - 1 ? "border-b border-[var(--cm-border)]" : "")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--cm-bg)] p-4 text-[13px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{row.dimension}
|
||||
</div>
|
||||
<div
|
||||
className="border-l border-[var(--cm-border)] bg-[var(--cm-bg)] p-4 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{row.mcp}
|
||||
</div>
|
||||
<div
|
||||
className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[13px] leading-[1.5] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{row.mesh}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Key insight */}
|
||||
<Reveal delay={6}>
|
||||
<blockquote
|
||||
className="mx-auto mt-14 max-w-3xl border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
MCP gave Claude hands to use tools. claudemesh gives Claudes ears to
|
||||
hear each other. The protocol is the same — the topology changes.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,14 @@ import Link from "next/link";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const SHIPPING = [
|
||||
"CLI + MCP server (Claude Code integration)",
|
||||
"CLI + 43 MCP tools (Claude Code integration)",
|
||||
"Hosted broker on claudemesh.com",
|
||||
"End-to-end encrypted direct messages (crypto_box)",
|
||||
"E2E encrypted messaging + file sharing",
|
||||
"Priority routing (now / next / low)",
|
||||
"Mesh invites + membership",
|
||||
"Windows, macOS, Linux support",
|
||||
"Shared state, memory, tasks, and streams",
|
||||
"Per-mesh SQL database, vector search, and graph DB",
|
||||
"Scheduled messages and reminders",
|
||||
"Mesh invites + ed25519 identity",
|
||||
];
|
||||
|
||||
const ROADMAP = [
|
||||
@@ -134,6 +136,36 @@ export const Pricing = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Enterprise tier */}
|
||||
<Reveal delay={4}>
|
||||
<div className="mx-auto mt-6 max-w-[720px] rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-6 md:p-8">
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3
|
||||
className="text-[18px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Enterprise
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Self-hosted broker. SSO. Custom SAML. Dedicated support.
|
||||
Air-gapped deployment. SLA.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:info@claudemesh.com"
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-2.5 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Contact sales
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
223
apps/web/src/modules/marketing/home/timeline.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const MILESTONES = [
|
||||
{
|
||||
version: "v0.1",
|
||||
phase: "Foundation",
|
||||
color: "var(--cm-clay)",
|
||||
items: [
|
||||
"E2E encrypted messaging (libsodium crypto_box)",
|
||||
"WSS broker with reconnect + priority routing",
|
||||
"ed25519 identity + signed invite links",
|
||||
"claudemesh launch with dev-channel push",
|
||||
"Named sessions + ephemeral keypairs",
|
||||
"Production hardening (stale sweep, sender exclusion)",
|
||||
],
|
||||
stat: "16 releases",
|
||||
},
|
||||
{
|
||||
version: "v0.2",
|
||||
phase: "Groups",
|
||||
color: "var(--cm-fig)",
|
||||
items: [
|
||||
"@group routing with roles (lead, member, observer)",
|
||||
"Interactive wizard for launch configuration",
|
||||
"Dynamic join/leave groups at runtime",
|
||||
"Multicast delivery with sender exclusion",
|
||||
],
|
||||
stat: "6 coordination patterns",
|
||||
},
|
||||
{
|
||||
version: "v0.3",
|
||||
phase: "Shared Intelligence",
|
||||
color: "var(--cm-cactus)",
|
||||
items: [
|
||||
"Shared state — live key-value with push notifications",
|
||||
"Memory — persistent knowledge with full-text search",
|
||||
"Message status — per-recipient delivery tracking",
|
||||
"MCP instructions — dynamic identity + tool guide",
|
||||
],
|
||||
stat: "Peers learn collectively",
|
||||
},
|
||||
{
|
||||
version: "v0.4",
|
||||
phase: "Files & Targeting",
|
||||
color: "var(--cm-oat)",
|
||||
items: [
|
||||
"MinIO file sharing with per-peer access control",
|
||||
"Message attachments (ephemeral, 24h TTL)",
|
||||
"Multi-target messages with deduplication",
|
||||
"Targeted views — per-audience message tailoring",
|
||||
],
|
||||
stat: "Binary artifacts + text",
|
||||
},
|
||||
{
|
||||
version: "v0.5",
|
||||
phase: "Data Platform",
|
||||
color: "var(--cm-clay)",
|
||||
items: [
|
||||
"Per-mesh SQL database (Postgres schema)",
|
||||
"Vector search (Qdrant semantic embeddings)",
|
||||
"Graph database (Neo4j entity relationships)",
|
||||
"Context sharing between peer sessions",
|
||||
"Tasks — create, claim, complete work items",
|
||||
"Streams — real-time pub/sub data channels",
|
||||
],
|
||||
stat: "5 persistence backends",
|
||||
},
|
||||
{
|
||||
version: "v0.6–0.8",
|
||||
phase: "Platform",
|
||||
color: "var(--cm-fig)",
|
||||
items: [
|
||||
"Mesh MCP proxy — dynamic tool sharing between peers",
|
||||
"Skills catalog — publish + discover reusable instructions",
|
||||
"Signed hash-chain audit log for mesh events",
|
||||
"Inbound webhooks for external integrations",
|
||||
"Scheduled messages + cron-based reminders",
|
||||
"Mesh services — deploy MCP servers with vault + scopes",
|
||||
"Runner container for git/npx service sources",
|
||||
"URL watch — broker polls URLs, notifies on change",
|
||||
"Telegram bridge with multi-tenant routing",
|
||||
"Peer stats reporting (messages, uptime, errors)",
|
||||
],
|
||||
stat: "43 MCP tools total",
|
||||
},
|
||||
];
|
||||
|
||||
export const Timeline = () => {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32">
|
||||
<div className="mx-auto max-w-[var(--cm-max-w)]">
|
||||
<Reveal className="mb-6 flex justify-center">
|
||||
<SectionIcon glyph="layers" />
|
||||
</Reveal>
|
||||
<Reveal delay={1}>
|
||||
<h2
|
||||
className="text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Shipped, not promised
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
className="mx-auto mt-4 max-w-xl text-center text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
66 npm releases. Every feature below is in production today.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<div ref={trackRef} className="relative mt-16">
|
||||
{/* Vertical line */}
|
||||
<div
|
||||
className="absolute left-[24px] top-0 hidden h-full w-px md:block"
|
||||
style={{ background: "linear-gradient(to bottom, var(--cm-clay), var(--cm-fig), var(--cm-cactus), transparent)" }}
|
||||
/>
|
||||
|
||||
<div className="space-y-12 md:space-y-16">
|
||||
{MILESTONES.map((m, idx) => (
|
||||
<div key={m.version} className="relative md:pl-16">
|
||||
{/* Dot on timeline */}
|
||||
<div
|
||||
className="absolute left-[17px] top-[6px] hidden h-[15px] w-[15px] rounded-full border-2 md:block"
|
||||
style={{
|
||||
borderColor: m.color,
|
||||
backgroundColor: "var(--cm-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-[3px] rounded-full"
|
||||
style={{ backgroundColor: m.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 transition-colors hover:border-[color:var(--hover-color)]"
|
||||
style={{ "--hover-color": m.color } as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-baseline justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="rounded-[4px] px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
fontFamily: "var(--cm-font-mono)",
|
||||
backgroundColor: m.color,
|
||||
color: "var(--cm-gray-900)",
|
||||
}}
|
||||
>
|
||||
{m.version}
|
||||
</span>
|
||||
<h3
|
||||
className="text-[18px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{m.phase}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
className="hidden shrink-0 text-[11px] text-[var(--cm-fg-tertiary)] sm:block"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.stat}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items grid */}
|
||||
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{m.items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-start gap-2 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<span
|
||||
className="mt-[7px] block h-[5px] w-[5px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: m.color, opacity: 0.6 }}
|
||||
/>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom: what's next */}
|
||||
<div className="relative mt-12 md:pl-16">
|
||||
<div
|
||||
className="absolute left-[17px] top-[6px] hidden h-[15px] w-[15px] rounded-full border-2 border-dashed border-[var(--cm-fg-tertiary)] md:block"
|
||||
/>
|
||||
<div
|
||||
className="rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="rounded-[4px] border border-[var(--cm-fg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
next
|
||||
</span>
|
||||
<span
|
||||
className="text-[14px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Dashboard · Slack bridge · self-host packaging · SSO
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -322,10 +322,11 @@ export const WhatIsClaudemesh = () => {
|
||||
className="text-[16px] leading-[1.65] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh of Claudes. Each keeps its own repo, memory, history.
|
||||
They reference each other on demand. Your identity travels
|
||||
across surfaces. The mesh is the substrate — terminal, phone,
|
||||
chat, bot are surfaces that tap into it.
|
||||
A mesh of Claudes. Each keeps its own repo and context.
|
||||
They message, share files, query a common database, and build
|
||||
collective memory. Your identity travels across surfaces.
|
||||
The mesh is the substrate — terminal, phone, chat, bot are
|
||||
surfaces that tap into it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,6 +414,110 @@ export const WhatIsClaudemesh = () => {
|
||||
))}
|
||||
</RevealStagger>
|
||||
|
||||
{/* Mesh structure */}
|
||||
<Reveal delay={1} className="mt-28">
|
||||
<div
|
||||
className="mb-8 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— mesh structure
|
||||
</div>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Tree diagram */}
|
||||
<div
|
||||
className="mx-auto max-w-xl rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8"
|
||||
>
|
||||
<pre
|
||||
className="text-[12px] leading-[1.8] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>{`Organization (billing, auth)
|
||||
└── Mesh (team workspace, persists)
|
||||
├── @frontend (group · 3 peers)
|
||||
│ ├── Alice [lead] working "implementing auth UI"
|
||||
│ ├── Bob [member] idle
|
||||
│ └── Carol [member] working "CSS grid refactor"
|
||||
├── @backend (group · 2 peers)
|
||||
│ ├── Dave [lead] working "API rate limiting"
|
||||
│ └── Eve [member] dnd
|
||||
├── @reviewers (group · 4 peers)
|
||||
│ └── Alice, Bob, Dave, Frank
|
||||
├── State (live key-value)
|
||||
│ ├── sprint: "2026-W14"
|
||||
│ ├── deploy_frozen: true
|
||||
│ └── pr_queue: ["#142", "#143"]
|
||||
└── Memory (institutional knowledge)
|
||||
├── "Payments API rate-limits at 100 req/s"
|
||||
└── "Auth tokens expire after 30min (March fix)"`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Coordination patterns */}
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{([
|
||||
{
|
||||
name: "Lead-gather",
|
||||
desc: "Lead sends to @group. Members respond. Lead synthesizes.",
|
||||
code: "send_message(to: \"@frontend\", ...)",
|
||||
},
|
||||
{
|
||||
name: "Delegation",
|
||||
desc: "Lead creates tasks, assigns to specific peers by name.",
|
||||
code: "create_task(title: \"...\", assignee: \"Bob\")",
|
||||
},
|
||||
{
|
||||
name: "Voting",
|
||||
desc: "Members write state. Lead tallies votes. Majority decides.",
|
||||
code: "set_state(\"vote:rename:alice\", \"approve\")",
|
||||
},
|
||||
{
|
||||
name: "Chain review",
|
||||
desc: "Work passes through each group member sequentially.",
|
||||
code: "send_message(to: \"Bob\", ...) → Bob → Carol",
|
||||
},
|
||||
{
|
||||
name: "Broadcast",
|
||||
desc: "Everyone responds independently. No coordinator.",
|
||||
code: "send_message(to: \"*\", ...)",
|
||||
},
|
||||
{
|
||||
name: "Targeted views",
|
||||
desc: "Different message per audience. Frontend gets hooks, PM gets status.",
|
||||
code: "send(\"@frontend\", ...); send(\"@pm\", ...)",
|
||||
},
|
||||
] as const).map((pattern) => (
|
||||
<div
|
||||
key={pattern.name}
|
||||
className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-5"
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[14px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{pattern.name}
|
||||
</div>
|
||||
<p
|
||||
className="mb-3 text-[12px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{pattern.desc}
|
||||
</p>
|
||||
<code
|
||||
className="text-[10px] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{pattern.code}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="mt-6 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
All patterns are conventions in system prompts. The broker routes; Claude coordinates.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Architecture diagram */}
|
||||
<Reveal delay={1} className="mt-28">
|
||||
<div
|
||||
@@ -424,6 +529,64 @@ export const WhatIsClaudemesh = () => {
|
||||
<MeshDiagram />
|
||||
</Reveal>
|
||||
|
||||
{/* Capability stack */}
|
||||
<Reveal delay={1} className="mx-auto mt-16 max-w-3xl">
|
||||
<div
|
||||
className="mb-8 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— what flows through the wire
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{([
|
||||
{ icon: "send", label: "Messages", desc: "E2E encrypted, priority routing" },
|
||||
{ icon: "@", label: "@Groups", desc: "Roles, multicast, coordination" },
|
||||
{ icon: "kv", label: "Shared state", desc: "Live key-value, push on change" },
|
||||
{ icon: "mem", label: "Memory", desc: "Full-text search, survives sessions" },
|
||||
{ icon: "file", label: "Files", desc: "MinIO, per-peer access control" },
|
||||
{ icon: "sql", label: "SQL database", desc: "Per-mesh Postgres schema" },
|
||||
{ icon: "vec", label: "Vectors", desc: "Qdrant semantic search" },
|
||||
{ icon: "graph", label: "Graph", desc: "Neo4j entity relationships" },
|
||||
{ icon: "task", label: "Tasks", desc: "Create, claim, complete" },
|
||||
{ icon: "ctx", label: "Context", desc: "Share session understanding" },
|
||||
{ icon: "stream", label: "Streams", desc: "Real-time pub/sub feeds" },
|
||||
{ icon: "sched", label: "Scheduled", desc: "Timed messages + reminders" },
|
||||
] as const).map((cap) => (
|
||||
<div
|
||||
key={cap.label}
|
||||
className="flex items-start gap-3 rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 shrink-0 text-[11px] font-medium text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{cap.icon}
|
||||
</span>
|
||||
<div>
|
||||
<div
|
||||
className="text-[13px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{cap.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{cap.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="mt-6 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
43 MCP tools · 5 persistence backends · every call E2E encrypted
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* What it's NOT */}
|
||||
<Reveal delay={2} className="mx-auto mt-24 max-w-3xl">
|
||||
<div
|
||||
@@ -457,10 +620,11 @@ export const WhatIsClaudemesh = () => {
|
||||
className="border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh adds a secure wire and a shared identity between the AI
|
||||
sessions you already run. Your Claudes stay specialized — each
|
||||
knows its own repo. The mesh lets them reference each other's
|
||||
work when useful. The human coordinates once, instead of N times.
|
||||
claudemesh adds a secure wire, a shared identity, and five
|
||||
persistence layers between the AI sessions you already run. Your
|
||||
Claudes stay specialized — each knows its own repo. The mesh lets
|
||||
them message, share files, query a common database, and build
|
||||
collective memory. The human coordinates once, instead of N times.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const columns = [
|
||||
{
|
||||
label: "product",
|
||||
items: [
|
||||
{ title: "Getting Started", href: pathsConfig.marketing.gettingStarted },
|
||||
{ title: "Docs", href: "#docs" },
|
||||
{ title: "Pricing", href: pathsConfig.marketing.pricing },
|
||||
{ title: "Changelog", href: "#changelog" },
|
||||
@@ -75,8 +76,8 @@ export const Footer = () => {
|
||||
className="text-sm leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Peer mesh for Claude Code. Every session, woven into one mesh —
|
||||
reachable from anywhere you are.
|
||||
Peer mesh for Claude Code. Messaging, files, databases, vectors,
|
||||
graphs — E2E encrypted. Every session, woven into one mesh.
|
||||
</p>
|
||||
<I18nControls />
|
||||
<div className="mt-2 flex items-center gap-2.5">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const NAV = [
|
||||
{ label: "Docs", href: "#docs" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Changelog", href: "#changelog" },
|
||||
{ label: "Docs", href: "https://github.com/alezmad/claudemesh-cli#readme" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Changelog", href: "/changelog" },
|
||||
] as const;
|
||||
|
||||
const OSS_REPO_URL = "https://github.com/alezmad/claude-intercom";
|
||||
const OSS_REPO_URL = "https://github.com/alezmad/claudemesh-cli";
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
@@ -66,8 +67,8 @@ export const Header = () => {
|
||||
href={OSS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="claude-intercom (MIT open source) on GitHub"
|
||||
title="Built on claude-intercom · MIT open source"
|
||||
aria-label="claudemesh-cli on GitHub"
|
||||
title="claudemesh-cli · MIT open source"
|
||||
className="hidden rounded-[var(--cm-radius-xs)] p-2 text-[var(--cm-fg-secondary)] transition-colors hover:bg-[var(--cm-bg-elevated)] hover:text-[var(--cm-fg)] md:inline-flex"
|
||||
>
|
||||
<svg
|
||||
|
||||
138
apps/web/src/modules/mesh/peer-graph-panel.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import {
|
||||
PeerGraph,
|
||||
type GraphPeer,
|
||||
type GraphEdge,
|
||||
} from "~/modules/mesh/peer-graph";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transform broker response into graph-friendly structures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildGraphData = (data: GetMyMeshStreamResponse) => {
|
||||
// Count messages per sender
|
||||
const countMap = new Map<string, number>();
|
||||
for (const e of data.envelopes) {
|
||||
countMap.set(e.senderMemberId, (countMap.get(e.senderMemberId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const peers: GraphPeer[] = data.presences.map((p) => ({
|
||||
id: p.memberId,
|
||||
name: p.displayName ?? p.memberId.slice(0, 8),
|
||||
status: p.status === "dnd" ? "dnd" : p.status,
|
||||
messageCount: countMap.get(p.memberId) ?? 0,
|
||||
}));
|
||||
|
||||
const edges: GraphEdge[] = data.envelopes.map((e) => ({
|
||||
key: e.id,
|
||||
from: e.senderMemberId,
|
||||
to: e.targetSpec === "*" ? null : e.targetSpec,
|
||||
priority: e.priority,
|
||||
createdAt: new Date(e.createdAt),
|
||||
}));
|
||||
|
||||
return { peers, edges };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Panel component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const PeerGraphPanel = ({ meshId }: { meshId: string }) => {
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const { peers, edges } = useMemo(
|
||||
() => (data ? buildGraphData(data) : { peers: [], edges: [] }),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
peer graph
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{peers.length} peers ·{" "}
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Graph area */}
|
||||
<div className="relative aspect-square w-full min-h-[320px]">
|
||||
<PeerGraph peers={peers} edges={edges} />
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
idle
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
|
||||
working
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#c46686]" />
|
||||
dnd
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--cm-border)]">|</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-emerald-500" />
|
||||
low
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-[var(--cm-fg-secondary)]" />
|
||||
next
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-red-500" />
|
||||
now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
462
apps/web/src/modules/mesh/peer-graph.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PeerStatus } from "~/modules/marketing/home/mesh-stream";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface GraphPeer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PeerStatus;
|
||||
summary?: string;
|
||||
/** Number of messages sent by this peer — drives node sizing */
|
||||
messageCount: number;
|
||||
/** Group names this peer belongs to */
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
key: string;
|
||||
from: string;
|
||||
to: string | null; // null = broadcast (draw to all)
|
||||
priority: "now" | "next" | "low";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PeerGraphProps {
|
||||
peers: GraphPeer[];
|
||||
edges: GraphEdge[];
|
||||
meshName?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<PeerStatus, string> = {
|
||||
idle: "#22c55e", // emerald-500
|
||||
working: "#d97757", // --cm-clay
|
||||
dnd: "#c46686", // --cm-fig
|
||||
offline: "#87867f", // --cm-fg-tertiary
|
||||
};
|
||||
|
||||
const PRIORITY_COLOR: Record<string, string> = {
|
||||
low: "#22c55e",
|
||||
next: "#c2c0b6",
|
||||
now: "#ef4444",
|
||||
};
|
||||
|
||||
/** How long edges remain visible (ms) */
|
||||
const EDGE_TTL_MS = 8_000;
|
||||
|
||||
/** Ring colors for groups — up to 8 distinct groups */
|
||||
const GROUP_RING_COLORS = [
|
||||
"#d97757", // clay
|
||||
"#c46686", // fig
|
||||
"#bcd1ca", // cactus
|
||||
"#e3dacc", // oat
|
||||
"#6ea8fe", // blue
|
||||
"#fbbf24", // amber
|
||||
"#a78bfa", // violet
|
||||
"#f472b6", // pink
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Radial layout: peers on a circle, center reserved for mesh label. */
|
||||
const computeLayout = (
|
||||
peerCount: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) => {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const radius = Math.min(cx, cy) * 0.68;
|
||||
return { cx, cy, radius };
|
||||
};
|
||||
|
||||
const peerPosition = (
|
||||
index: number,
|
||||
total: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
) => {
|
||||
const angle = (index / total) * 2 * Math.PI - Math.PI / 2; // start at top
|
||||
return {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
/** Scale node radius based on message volume relative to peers. */
|
||||
const nodeRadius = (count: number, maxCount: number) => {
|
||||
const base = 22;
|
||||
const extra = 12;
|
||||
if (maxCount === 0) return base;
|
||||
return base + (count / maxCount) * extra;
|
||||
};
|
||||
|
||||
/** Build a group-color map from all peers. */
|
||||
const buildGroupColorMap = (peers: GraphPeer[]) => {
|
||||
const seen = new Set<string>();
|
||||
for (const p of peers) {
|
||||
for (const g of p.groups ?? []) seen.add(g);
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
let i = 0;
|
||||
for (const g of seen) {
|
||||
map.set(g, GROUP_RING_COLORS[i % GROUP_RING_COLORS.length]!);
|
||||
i++;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/** Quadratic bezier control point offset for curved edges */
|
||||
const curveOffset = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
) => {
|
||||
// Push the control point toward center for a slight curve
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
const factor = 0.15;
|
||||
return {
|
||||
qx: mx + (cx - mx) * factor,
|
||||
qy: my + (cy - my) * factor,
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const PeerGraph = ({ peers, edges, meshName }: PeerGraphProps) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 520, height: 520 });
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// Tick every second to fade edges
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Responsive resize
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) setDimensions({ width, height });
|
||||
});
|
||||
ro.observe(svg);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const { width, height } = dimensions;
|
||||
const { cx, cy, radius } = computeLayout(peers.length, width, height);
|
||||
const maxCount = useMemo(
|
||||
() => Math.max(1, ...peers.map((p) => p.messageCount)),
|
||||
[peers],
|
||||
);
|
||||
const groupColorMap = useMemo(() => buildGroupColorMap(peers), [peers]);
|
||||
|
||||
// Map peer id -> position
|
||||
const posMap = useMemo(() => {
|
||||
const m = new Map<string, { x: number; y: number }>();
|
||||
peers.forEach((p, i) => {
|
||||
m.set(p.id, peerPosition(i, peers.length, cx, cy, radius));
|
||||
});
|
||||
return m;
|
||||
}, [peers, cx, cy, radius]);
|
||||
|
||||
// Filter edges to those still visible
|
||||
const visibleEdges = useMemo(
|
||||
() => edges.filter((e) => now - e.createdAt.getTime() < EDGE_TTL_MS),
|
||||
[edges, now],
|
||||
);
|
||||
|
||||
// Build edge paths: direct -> single path, broadcast -> one path per peer
|
||||
const edgePaths = useMemo(() => {
|
||||
const paths: {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}[] = [];
|
||||
|
||||
for (const e of visibleEdges) {
|
||||
const fromPos = posMap.get(e.from);
|
||||
if (!fromPos) continue;
|
||||
const age = now - e.createdAt.getTime();
|
||||
const opacity = Math.max(0, 1 - age / EDGE_TTL_MS);
|
||||
const color = PRIORITY_COLOR[e.priority] ?? PRIORITY_COLOR.next!;
|
||||
|
||||
if (e.to === null || e.to === "*") {
|
||||
// Broadcast: lines to all other peers
|
||||
for (const [pid, pos] of posMap) {
|
||||
if (pid === e.from) continue;
|
||||
const { qx, qy } = curveOffset(
|
||||
fromPos.x,
|
||||
fromPos.y,
|
||||
pos.x,
|
||||
pos.y,
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
paths.push({
|
||||
key: `${e.key}-${pid}`,
|
||||
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${pos.x},${pos.y}`,
|
||||
color,
|
||||
opacity: opacity * 0.6,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const toPos = posMap.get(e.to);
|
||||
if (!toPos) continue;
|
||||
const { qx, qy } = curveOffset(
|
||||
fromPos.x,
|
||||
fromPos.y,
|
||||
toPos.x,
|
||||
toPos.y,
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
paths.push({
|
||||
key: e.key,
|
||||
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${toPos.x},${toPos.y}`,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}, [visibleEdges, posMap, cx, cy, now]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="h-full w-full"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={`Peer graph for mesh${meshName ? ` "${meshName}"` : ""} showing ${peers.length} peers and recent message traffic`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{/* Subtle radial grid */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 6"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius * 0.5}
|
||||
fill="none"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="2 4"
|
||||
opacity="0.2"
|
||||
/>
|
||||
|
||||
{/* Center mesh label */}
|
||||
{meshName && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="11"
|
||||
opacity="0.5"
|
||||
>
|
||||
{meshName}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Edges */}
|
||||
<g>
|
||||
{edgePaths.map((ep) => (
|
||||
<path
|
||||
key={ep.key}
|
||||
d={ep.d}
|
||||
fill="none"
|
||||
stroke={ep.color}
|
||||
strokeWidth="1.5"
|
||||
opacity={ep.opacity}
|
||||
style={{
|
||||
transition: "opacity 1s ease-out",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Animated pulse dots traveling along edges */}
|
||||
{edgePaths
|
||||
.filter((ep) => ep.opacity > 0.3)
|
||||
.map((ep) => (
|
||||
<circle key={`dot-${ep.key}`} r="2.5" fill={ep.color} opacity={ep.opacity}>
|
||||
<animateMotion
|
||||
dur="1.2s"
|
||||
repeatCount="1"
|
||||
path={ep.d}
|
||||
fill="freeze"
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* Peer nodes */}
|
||||
{peers.map((peer, i) => {
|
||||
const pos = posMap.get(peer.id);
|
||||
if (!pos) return null;
|
||||
const r = nodeRadius(peer.messageCount, maxCount);
|
||||
const groups = peer.groups ?? [];
|
||||
|
||||
return (
|
||||
<g key={peer.id}>
|
||||
{/* Group rings (concentric, outermost first) */}
|
||||
{groups.map((g, gi) => {
|
||||
const ringR = r + 5 + gi * 4;
|
||||
const ringColor = groupColorMap.get(g) ?? GROUP_RING_COLORS[0]!;
|
||||
return (
|
||||
<circle
|
||||
key={g}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={ringR}
|
||||
fill="none"
|
||||
stroke={ringColor}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 3"
|
||||
opacity="0.55"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer glow for active status */}
|
||||
{peer.status === "working" && (
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={r + 2}
|
||||
fill="none"
|
||||
stroke={STATUS_COLOR.working}
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values={`${r + 2};${r + 6};${r + 2}`}
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.3;0.08;0.3"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
)}
|
||||
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={r}
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke={STATUS_COLOR[peer.status]}
|
||||
strokeWidth="2"
|
||||
style={{ transition: "all 0.6s var(--cm-ease)" }}
|
||||
/>
|
||||
|
||||
{/* Status indicator dot */}
|
||||
<circle
|
||||
cx={pos.x + r * 0.6}
|
||||
cy={pos.y - r * 0.6}
|
||||
r="4"
|
||||
fill={STATUS_COLOR[peer.status]}
|
||||
stroke="var(--cm-bg)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Initials inside node */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
>
|
||||
{peer.name.slice(0, 2).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Name label below */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + r + 14}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-secondary)"
|
||||
fontSize="10"
|
||||
>
|
||||
{peer.name.length > 12
|
||||
? peer.name.slice(0, 11) + "\u2026"
|
||||
: peer.name}
|
||||
</text>
|
||||
|
||||
{/* Truncated summary below name */}
|
||||
{peer.summary && (
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + r + 26}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="8"
|
||||
>
|
||||
{peer.summary.length > 24
|
||||
? peer.summary.slice(0, 23) + "\u2026"
|
||||
: peer.summary}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{peers.length === 0 && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="12"
|
||||
>
|
||||
No peers connected
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -83,6 +83,25 @@ services:
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
|
||||
runner:
|
||||
build:
|
||||
context: ./apps/runner
|
||||
restart: always
|
||||
environment:
|
||||
RUNNER_PORT: 7901
|
||||
volumes:
|
||||
- services-data:/var/claudemesh/services
|
||||
expose:
|
||||
- "7901"
|
||||
networks:
|
||||
- claudemesh-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:7901/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
broker:
|
||||
image: ${BROKER_IMAGE:-claudemesh-broker:latest}
|
||||
restart: always
|
||||
@@ -103,6 +122,11 @@ services:
|
||||
NEO4J_URL: bolt://neo4j:7687
|
||||
NEO4J_USER: neo4j
|
||||
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
|
||||
RUNNER_URL: http://runner:7901
|
||||
CLAUDEMESH_SERVICES_DIR: /var/claudemesh/services
|
||||
BROKER_ENCRYPTION_KEY: ${BROKER_ENCRYPTION_KEY:-}
|
||||
volumes:
|
||||
- services-data:/var/claudemesh/services
|
||||
expose:
|
||||
- "7900"
|
||||
networks:
|
||||
@@ -115,6 +139,8 @@ services:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
runner:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
@@ -159,6 +185,7 @@ volumes:
|
||||
minio-data:
|
||||
qdrant-data:
|
||||
neo4j-data:
|
||||
services-data:
|
||||
|
||||
networks:
|
||||
# Coolify's shared Traefik network — must already exist on the host
|
||||
|
||||
164
docs/changelog-20260407.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# claudemesh — Implementation Changelog
|
||||
|
||||
**Sprint:** 2026-04-07 evening session
|
||||
**Author:** Alejandro Gutiérrez + Claude (Opus 4.6)
|
||||
**CLI versions:** 0.6.8 → 0.6.9 → 0.7.0
|
||||
**Broker:** deployed to `ic.claudemesh.com` (Coolify, OVHcloud VPS)
|
||||
|
||||
---
|
||||
|
||||
## Features shipped
|
||||
|
||||
### 1. Session path (cwd) sharing
|
||||
`810f372` · CLI 0.6.9 + broker
|
||||
|
||||
Added `cwd` to the WS hello handshake. Broker stores it in the peer record, `list_peers` returns it. Peers on the same machine see each other's working directories for direct file referencing.
|
||||
|
||||
### 2. Peer metadata (type, channel, model)
|
||||
`810f372` · Same commit as cwd
|
||||
|
||||
Extended hello with `peerType: "ai" | "human" | "connector"`, `channel` (e.g. "claude-code", "telegram"), `model` (e.g. "opus-4"). Foundation for connectors, humans, and smart routing.
|
||||
|
||||
### 3. System notifications (peer join/leave)
|
||||
`453705a` · broker + CLI
|
||||
|
||||
Broker broadcasts `{ subtype: "system", event: "peer_joined" | "peer_left" }` pushes to all mesh peers on connect/disconnect. MCP server formats them as `[system] Peer "Alice" joined the mesh`. System events bypass inbox/off message modes.
|
||||
|
||||
### 4. Cron-based persistent reminders
|
||||
`e873807` · broker + CLI + `72be651` (--cron flag)
|
||||
|
||||
Replaced in-memory `setTimeout` with DB-persisted scheduler. Zero-dependency 5-field cron parser. Schedules survive broker restarts via `recoverScheduledMessages()` on boot. CLI: `claudemesh remind "check deploys" --cron "0 */2 * * *"`. MCP: `schedule_reminder` with `cron` field.
|
||||
|
||||
### 5. Simulation clock with time multiplier
|
||||
`05d9b56` · broker + CLI
|
||||
|
||||
Per-mesh clock state (`MeshClock` interface + `meshClocks` Map). Configurable speed x1–x100. Broadcasts heartbeat ticks as system pushes: `{ event: "tick", eventData: { tick, simTime, speed } }`. Auto-pauses when last peer disconnects. MCP tools: `mesh_set_clock`, `mesh_pause_clock`, `mesh_resume_clock`, `mesh_clock`.
|
||||
|
||||
### 6. Inbound webhooks
|
||||
`b55cf26` · broker (new `webhooks.ts`) + CLI
|
||||
|
||||
`POST /hook/:meshId/:secret` → broker injects as push to all mesh peers. Webhooks stored in `meshWebhook` Drizzle table. MCP tools: `create_webhook` (returns URL+secret), `list_webhooks`, `delete_webhook`. Push format: `{ subtype: "webhook", event: "webhook_name", eventData: {...body} }`.
|
||||
|
||||
### 7. Slack connector
|
||||
`5563f90` · `packages/connector-slack/`
|
||||
|
||||
Bridge process using `@slack/socket-mode` + `@slack/web-api`. Joins mesh as `peerType: "connector"`, `channel: "slack"`. Bidirectional relay with echo prevention, user ID-to-name resolution with caching, auto-reconnect with exponential backoff.
|
||||
|
||||
### 8. Telegram connector
|
||||
`fe92853` · `packages/connector-telegram/`
|
||||
|
||||
Zero-dependency Telegram Bot API client using native `fetch` + long polling. Same bridge pattern as Slack. HTML formatting for Telegram output. Auto-reconnect with exponential backoff (1s–30s).
|
||||
|
||||
### 9. Non-Claude-Code SDK
|
||||
`7e102a2` · `packages/sdk/`
|
||||
|
||||
Standalone TypeScript SDK (`@claudemesh/sdk`). `MeshClient extends EventEmitter` with `connect()`, `send()`, `broadcast()`, `listPeers()`, `getState()`, `setState()`. Uses `libsodium-wrappers` for ed25519-to-curve25519 crypto_box encryption (same as CLI). Auto-reconnect with exponential backoff.
|
||||
|
||||
### 10. Mesh skills catalog
|
||||
`c8cb1e3` · broker (Drizzle schema + handlers) + CLI
|
||||
|
||||
Peers publish reusable skills (name, description, instructions, tags). Full CRUD: `share_skill` (upsert by name), `get_skill`, `list_skills` (ILIKE search), `remove_skill`. Stored in `meshSkill` table with unique (meshId, name). `get_skill` returns instructions prominently formatted for immediate AI use.
|
||||
|
||||
### 11. Shared project files
|
||||
`504111c` · broker relay + CLI file serving
|
||||
|
||||
Peer-to-peer file relay: `read_peer_file(peer, path)` and `list_peer_files(peer, path?, pattern?)`. Broker relays without reading content. Security: 1MB max, path traversal rejection, hidden files excluded, 2-level dir listing cap (500 entries). Plus hostname-based local/remote detection (`2c9c8c7`) and filesystem shortcut hint for local peers (`a92cf6b`).
|
||||
|
||||
### 12. Peer stats reporting
|
||||
`b3b9972` · broker + CLI
|
||||
|
||||
Peers auto-report stats every 60s: messagesIn/Out, toolCalls, uptime, errors. `set_stats` WS message + `mesh_stats` MCP tool. Stats visible in `list_peers` response. Tool call counter incremented on every MCP invocation.
|
||||
|
||||
### 13. Signed audit log (hash chain)
|
||||
`86a2583` · broker (new `audit.ts` + Drizzle schema)
|
||||
|
||||
SHA-256 hash-chained append-only log. Each entry hashes: `prevHash|meshId|eventType|actorMemberId|payload|createdAt`. Events logged: peer_joined, peer_left, state_set, message_sent (NO ciphertext). WS endpoints: `audit_query` (paginated), `audit_verify` (chain integrity check). On startup: `ensureAuditLogTable()` + `loadLastHashes()`.
|
||||
|
||||
### 14. Mesh templates
|
||||
`69e93d4` · CLI (`apps/cli/src/templates/`)
|
||||
|
||||
5 JSON templates: dev-team, research, ops-incident, simulation, personal. Each defines groups, roles, state keys, and a system prompt hint. `claudemesh create --template dev-team` loads and displays template. `claudemesh create --list-templates` shows all.
|
||||
|
||||
### 15. Default personal mesh guidance
|
||||
`b0dc538` · CLI (`install.ts`)
|
||||
|
||||
`claudemesh install` detects empty meshes and shows join guidance. Local-only mesh deferred (requires broker enrollment for real connectivity).
|
||||
|
||||
### 16. Mesh MCP proxy
|
||||
`08e289a` · broker + CLI
|
||||
|
||||
Dynamic tool sharing: `mesh_mcp_register` → `mesh_mcp_list` → `mesh_tool_call` → broker forwards to hosting peer → execute → result back. In-memory registry with 30s call timeout. Auto-cleanup on disconnect. MCP register/unregister broadcasts system notifications (`e09671c`).
|
||||
|
||||
### 17. Dashboard: peer graph + state timeline + resource panel
|
||||
`59332dc` (peer graph) + `7d432b3` (timeline + resources)
|
||||
|
||||
**Peer graph:** Radial SVG layout, animated bezier edges with priority colors, group rings, status indicators (green/amber/red), node sizing by activity. No external deps (pure SVG + CSS animations). `ResizeObserver` for responsive sizing.
|
||||
|
||||
**State timeline:** Vertical timeline of audit events with timestamps, icons, type badges. Newest-first with auto-scroll. Shares same TanStack Query cache (zero extra API calls).
|
||||
|
||||
**Resource panel:** 2x2 card grid — live peers, envelope breakdown, audit event frequency, session online/offline split.
|
||||
|
||||
### 18. Peer visibility + public profiles
|
||||
Broker types.ts + index.ts + CLI
|
||||
|
||||
`set_visible(false)` makes peer invisible in `list_peers` and skips broadcast/group routing. Direct messages by pubkey still reach hidden peers. System events: `peer_visible`, `peer_hidden`. Public profiles: `set_profile({ avatar, title, bio, capabilities })` — visible to other peers in `list_peers` and peer graph.
|
||||
|
||||
### 19. Hostname + local/remote detection
|
||||
`2c9c8c7` · broker + CLI
|
||||
|
||||
`os.hostname()` added to hello handshake. `list_peers` shows `[local]` or `[remote]` tag per peer. MCP instructions include file access decision guide: local → filesystem, remote <1MB → `read_peer_file`, large/persistent → `share_file`.
|
||||
|
||||
### 20. File access decision guide in MCP instructions
|
||||
`3641618` · CLI MCP server
|
||||
|
||||
Clear decision guide in system instructions: three methods (filesystem for local, relay for remote, MinIO for persistent), with size limits and when to use each.
|
||||
|
||||
### 21. MCP server register/unregister broadcasts
|
||||
`e09671c` · broker + CLI
|
||||
|
||||
When a peer registers or removes an MCP server, all mesh peers receive a system notification: `[system] New MCP server available: "github" (hosted by Alice). Tools: list_repos, create_issue. Use mesh_tool_call to invoke.`
|
||||
|
||||
---
|
||||
|
||||
## Also shipped (infrastructure / docs)
|
||||
|
||||
| Commit | What |
|
||||
|--------|------|
|
||||
| `0bb9d71` | Merged `schedule_reminder` + `send_later` into single tool with optional `to` param; added `subtype: "reminder"` to push |
|
||||
| `79525af` | Fixed TSC error from cron example in JSDoc comment |
|
||||
| `69e93d4` | Mesh templates: 5 JSON templates + `claudemesh create` command |
|
||||
| `f34b8fb` | CLI `--help` text review: 44 descriptions improved for clarity, concision, consistency |
|
||||
| `58ba01f` | `CLAUDEMESH_TOOLS` in install.ts synced (41→45 tools, sorted alphabetically) |
|
||||
| `db2bf3e` | `protocol.md` expanded from 6 to 73 message types |
|
||||
| `72be651` | `--cron` flag wired into citty remind command |
|
||||
|
||||
---
|
||||
|
||||
## CLI versions published
|
||||
|
||||
| Version | Key changes |
|
||||
|---------|------------|
|
||||
| 0.6.8 | schedule_reminder merge, reminder subtype |
|
||||
| 0.6.9 | cwd + peer metadata + system notifications + cron + templates + --help review |
|
||||
| 0.7.0 | Skills catalog, MCP proxy, shared files, visibility, sim clock, webhooks, peer stats, connectors, SDK |
|
||||
|
||||
---
|
||||
|
||||
## Pending (building)
|
||||
|
||||
- **Peer session persistence** — agent running, DB-backed state restore on reconnect
|
||||
- **Persistent MCP registrations** — agent running, survive peer disconnect with online/offline status
|
||||
|
||||
---
|
||||
|
||||
## Remaining from vision (not yet built)
|
||||
|
||||
| # | Feature | Notes |
|
||||
|---|---------|-------|
|
||||
| 6 | REST API + external WS | Webhooks done, REST and WS auth remain |
|
||||
| 8 | Humans in the mesh | Web chat panel needed |
|
||||
| 14 | Bridge / federation | Bridge peer feasible now, federation needs design |
|
||||
| 18 | Sandboxes (E2B) | Third-party integration preferred |
|
||||
| 20 | Spatial topology (x,y proximity) | Visibility done, proximity model remains |
|
||||
| 21 | Semantic peer search | Multi-field matching, half day |
|
||||
| 22 | Mesh telemetry + debugging | Structured logging + reporting |
|
||||
719
docs/cli-auth-sync-spec.md
Normal file
@@ -0,0 +1,719 @@
|
||||
# CLI Auth Sync: Zero-Friction Onboarding
|
||||
|
||||
> Spec for syncing dashboard meshes to the CLI without manual join commands.
|
||||
> Goal: `npm i -g claudemesh-cli && claudemesh launch` — one install, one
|
||||
> command, even for users who already created meshes on the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Today a user who created a mesh on claudemesh.com must:
|
||||
1. `npm i -g claudemesh-cli`
|
||||
2. Go to dashboard → generate invite → copy token
|
||||
3. `claudemesh join <token>`
|
||||
4. `claudemesh launch --name Alice`
|
||||
|
||||
Steps 2-3 are friction. The dashboard already knows their meshes. The CLI
|
||||
should sync them automatically.
|
||||
|
||||
## Design goal
|
||||
|
||||
```bash
|
||||
npm i -g claudemesh-cli
|
||||
claudemesh launch --name Alice
|
||||
```
|
||||
|
||||
Two commands total. If the user has meshes on the dashboard, they appear
|
||||
automatically. If they have none, the CLI walks them through creating one.
|
||||
|
||||
**UX principles:**
|
||||
- **No menus on the happy path.** If the user typed `launch`, they want to
|
||||
launch — not answer 7 prompts. Default to browser sync, auto-pick the
|
||||
first mesh, default to `push` mode. Everything overridable with flags.
|
||||
- **Headless fallback.** SSH users can't open a browser. Always provide a
|
||||
pairing code + paste-token alternative.
|
||||
- **Sync anytime.** First-time wizard is not the only entry point. A
|
||||
standalone `claudemesh sync` command re-syncs meshes at any time.
|
||||
|
||||
---
|
||||
|
||||
## Identity model
|
||||
|
||||
Two separate auth systems exist today:
|
||||
|
||||
| System | Auth method | Where identity lives |
|
||||
|---|---|---|
|
||||
| **Dashboard** | Google OAuth (via Payload CMS) | `user` table in Postgres, session cookie |
|
||||
| **CLI/Broker** | ed25519 keypairs | `~/.claudemesh/config.json` + `mesh.member` table |
|
||||
|
||||
These are currently **unlinked**. The broker doesn't know which dashboard
|
||||
user owns a keypair, and the dashboard doesn't know a CLI user's pubkey.
|
||||
|
||||
### Keep them separate
|
||||
|
||||
Don't merge them into one auth system. OAuth is for web sessions. Ed25519
|
||||
is for peer identity and E2E crypto. They serve different purposes.
|
||||
|
||||
Instead, **link** them: a dashboard user can claim a CLI keypair, and vice
|
||||
versa. The link is stored in the DB and used for mesh sync.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
claudemesh launch --name Alice
|
||||
│
|
||||
├── 1. Check ~/.claudemesh/config.json
|
||||
│ Has meshes? → pick one, launch (existing flow)
|
||||
│
|
||||
├── 2. No meshes → check for linked dashboard account
|
||||
│ ~/.claudemesh/config.json has accountId? → fetch meshes from broker
|
||||
│ Has meshes on broker? → auto-enroll locally, launch
|
||||
│
|
||||
├── 3. No linked account → auto-start browser sync
|
||||
│ Generate 4-char pairing code (e.g. A3Kx)
|
||||
│ Start localhost callback listener
|
||||
│ Open browser: https://claudemesh.com/cli-auth?port=<port>&code=<code>
|
||||
│ Print fallback: "Can't open browser? Visit: <url>"
|
||||
│ Print fallback: "Or join with invite: claudemesh launch --join <url>"
|
||||
│
|
||||
│ Wait for sync token (from localhost redirect or manual paste)
|
||||
│
|
||||
└── 4. On sync token received
|
||||
├── Generate ed25519 keypair
|
||||
├── POST /cli-sync → broker creates members, returns mesh list
|
||||
├── Write all meshes + accountId to config
|
||||
├── Auto-select first mesh (or --mesh flag)
|
||||
└── Launch immediately (no further prompts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The sync token
|
||||
|
||||
A short-lived JWT issued by the dashboard after OAuth, containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user_abc123",
|
||||
"email": "alice@example.com",
|
||||
"meshes": [
|
||||
{ "id": "mesh_xyz", "slug": "dev-team", "role": "admin" },
|
||||
{ "id": "mesh_abc", "slug": "research", "role": "member" }
|
||||
],
|
||||
"action": "sync", // or "create"
|
||||
"newMesh": { // only if action=create
|
||||
"name": "My Team",
|
||||
"slug": "my-team"
|
||||
},
|
||||
"iat": 1712000000,
|
||||
"exp": 1712000900 // 15 min TTL
|
||||
}
|
||||
```
|
||||
|
||||
The CLI never sees the user's OAuth tokens. It only gets this sync token,
|
||||
which the broker validates and uses to create/find members.
|
||||
|
||||
**TTL: 15 minutes** (not 5). First-time users may need to create a Google
|
||||
account, go through OAuth consent, and create a mesh. The real protection
|
||||
is single-use JTI dedup, not a tight TTL.
|
||||
|
||||
---
|
||||
|
||||
## Broker: POST /cli-sync
|
||||
|
||||
New endpoint. Accepts a sync token, returns mesh details for each mesh.
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
POST /cli-sync
|
||||
{
|
||||
"sync_token": "<JWT>",
|
||||
"peer_pubkey": "<ed25519 hex>", // CLI's freshly generated keypair
|
||||
"display_name": "Alice"
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"ok": true,
|
||||
"account_id": "user_abc123",
|
||||
"meshes": [
|
||||
{
|
||||
"mesh_id": "mesh_xyz",
|
||||
"slug": "dev-team",
|
||||
"broker_url": "wss://ic.claudemesh.com/ws",
|
||||
"member_id": "member_123",
|
||||
"role": "admin"
|
||||
},
|
||||
{
|
||||
"mesh_id": "mesh_abc",
|
||||
"slug": "research",
|
||||
"broker_url": "wss://ic.claudemesh.com/ws",
|
||||
"member_id": "member_456",
|
||||
"role": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The broker:
|
||||
1. Validates the JWT signature and expiry
|
||||
2. Checks the JTI hasn't been used (in-memory Set, TTL-evicted)
|
||||
3. For each mesh: creates a `mesh.member` row with the CLI's pubkey (or
|
||||
reuses existing if this pubkey is already a member)
|
||||
4. Links the dashboard `user.id` to the `mesh.member` via a new
|
||||
`dashboard_user_id` column
|
||||
5. Returns mesh details so the CLI can write `config.json`
|
||||
|
||||
---
|
||||
|
||||
## Web: /cli-auth page
|
||||
|
||||
New page at `https://claudemesh.com/cli-auth?port=<port>&code=<code>`.
|
||||
|
||||
The `code` param is the 4-char pairing code displayed in the CLI terminal,
|
||||
shown on the page so the user can confirm they're syncing the right session.
|
||||
|
||||
### Flow
|
||||
|
||||
1. User lands on the page (already signed in via Google, or signs in now)
|
||||
2. Page shows their meshes + the pairing code for confirmation:
|
||||
```
|
||||
Sync with claudemesh CLI
|
||||
|
||||
Pairing code: A3Kx
|
||||
Confirm this matches your terminal.
|
||||
|
||||
Your meshes:
|
||||
☑ dev-team (3 members, admin)
|
||||
☑ research (1 member, member)
|
||||
|
||||
[Sync to CLI]
|
||||
```
|
||||
3. User clicks "Sync to CLI"
|
||||
4. Dashboard generates a sync JWT
|
||||
5. **Redirect attempt**: `http://localhost:<port>/callback?token=<JWT>`
|
||||
6. **If redirect fails** (port unreachable, headless, different device):
|
||||
show the token on-screen with copy button and instructions:
|
||||
```
|
||||
Couldn't reach your terminal automatically.
|
||||
Copy this token and paste it in your terminal:
|
||||
|
||||
[eyJhbGciOi...] [Copy]
|
||||
```
|
||||
|
||||
### Localhost reachability check
|
||||
|
||||
Before redirecting, the page does a preflight check:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/ping`, { signal: AbortSignal.timeout(2000) });
|
||||
if (res.ok) redirect(`http://localhost:${port}/callback?token=${jwt}`);
|
||||
else showManualToken(jwt);
|
||||
} catch {
|
||||
showManualToken(jwt);
|
||||
}
|
||||
```
|
||||
|
||||
The CLI's callback listener responds to `/ping` with 200 OK (no token needed).
|
||||
|
||||
### If user has no meshes
|
||||
|
||||
```
|
||||
Welcome to claudemesh!
|
||||
|
||||
You don't have any meshes yet. Let's create one.
|
||||
|
||||
Name: [My Team ]
|
||||
Slug: [my-team ]
|
||||
|
||||
[Create & sync to CLI]
|
||||
```
|
||||
|
||||
Creates the mesh, generates the sync token with the new mesh, redirects.
|
||||
|
||||
---
|
||||
|
||||
## CLI: localhost listener
|
||||
|
||||
Minimal HTTP server, adapted from Claude Code's `AuthCodeListener` pattern:
|
||||
|
||||
```typescript
|
||||
import { createServer } from "node:http";
|
||||
|
||||
interface CallbackListener {
|
||||
port: number;
|
||||
token: Promise<string>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function startCallbackListener(): Promise<CallbackListener> {
|
||||
return new Promise((resolveStart) => {
|
||||
let resolveToken: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((r) => { resolveToken = r; });
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost`);
|
||||
|
||||
if (url.pathname === "/ping") {
|
||||
// Reachability check from the web page
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/callback") {
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end(`<html><body>
|
||||
<h2>Done! You can close this tab.</h2>
|
||||
<p>Launching claudemesh...</p>
|
||||
</body></html>`);
|
||||
resolveToken(token);
|
||||
server.close();
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end("Missing token");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// CORS preflight for /ping
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolveStart({
|
||||
port: addr.port,
|
||||
token: tokenPromise,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI: first-time sync flow
|
||||
|
||||
In `launch.ts`, when `config.meshes.length === 0`:
|
||||
|
||||
```typescript
|
||||
if (config.meshes.length === 0 && !joinUrl) {
|
||||
// Generate pairing code (4 alphanumeric chars)
|
||||
const code = generatePairingCode();
|
||||
|
||||
// Start listener
|
||||
const listener = await startCallbackListener();
|
||||
const action = "sync";
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=${action}`;
|
||||
|
||||
console.log(`
|
||||
${bold("Welcome to claudemesh!")} No meshes found.
|
||||
Opening browser to sign in...
|
||||
`);
|
||||
|
||||
// Try to open browser (non-fatal if it fails)
|
||||
const opened = await openBrowser(url);
|
||||
|
||||
if (!opened) {
|
||||
console.log(` Couldn't open browser automatically.`);
|
||||
}
|
||||
|
||||
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}`);
|
||||
console.log();
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
askManualToken(), // "Paste sync token: " prompt (resolves on paste)
|
||||
timeout(15 * 60_000), // 15 min, matches JWT TTL
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error(" Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const keypair = await generateKeypair();
|
||||
const result = await syncWithBroker(syncToken, keypair, displayName);
|
||||
|
||||
// Write all meshes to config
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
console.log(` ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}`);
|
||||
}
|
||||
|
||||
// Auto-select mesh: first one, or --mesh flag
|
||||
const mesh = flags.mesh
|
||||
? config.meshes.find(m => m.slug === flags.mesh)
|
||||
: config.meshes[0];
|
||||
|
||||
if (!mesh) {
|
||||
console.error(`Mesh not found: ${flags.mesh}`);
|
||||
console.error(`Available: ${config.meshes.map(m => m.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Launch immediately with defaults
|
||||
// Role, groups, messageMode all use flag values or defaults (no prompts)
|
||||
```
|
||||
|
||||
### No prompts on the happy path
|
||||
|
||||
| Setting | Default | Override |
|
||||
|---|---|---|
|
||||
| Mesh | First in list | `--mesh <slug>` |
|
||||
| Role | *(none)* | `--role <role>` |
|
||||
| Groups | *(none)* | `--groups <a,b>` |
|
||||
| Message mode | `push` | `--message-mode <mode>` |
|
||||
| Confirmation | Skip on first sync | `-y` for all future launches |
|
||||
|
||||
The existing interactive prompts (role, groups, message mode) are kept
|
||||
for `claudemesh launch` when the user has meshes and runs without flags
|
||||
and without `--quiet`. But they're **skipped entirely on the first sync
|
||||
flow** — the user just signed in via browser, that's enough friction.
|
||||
|
||||
---
|
||||
|
||||
## CLI: `claudemesh sync` command
|
||||
|
||||
Standalone command for re-syncing meshes anytime:
|
||||
|
||||
```bash
|
||||
# Sync new meshes from dashboard
|
||||
claudemesh sync
|
||||
|
||||
# Force re-sync (re-link account even if already linked)
|
||||
claudemesh sync --force
|
||||
```
|
||||
|
||||
```typescript
|
||||
// commands/sync.ts
|
||||
export default defineCommand({
|
||||
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||
args: {
|
||||
force: { type: "boolean", description: "Re-link account even if already linked" },
|
||||
},
|
||||
async run({ args }) {
|
||||
const config = loadConfig();
|
||||
|
||||
// Start browser flow (same as first-time, but action=sync always)
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`Opening browser...`);
|
||||
console.log(dim(`Visit: ${url}`));
|
||||
await openBrowser(url);
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
askManualToken(),
|
||||
timeout(15 * 60_000),
|
||||
]);
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("Timed out.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use existing keypair from first mesh, or generate new
|
||||
const keypair = config.meshes.length > 0
|
||||
? { publicKey: config.meshes[0].pubkey, secretKey: config.meshes[0].secretKey }
|
||||
: await generateKeypair();
|
||||
|
||||
const result = await syncWithBroker(syncToken, keypair, config.displayName ?? "unnamed");
|
||||
|
||||
// Merge: add new meshes, skip duplicates
|
||||
let added = 0;
|
||||
for (const m of result.meshes) {
|
||||
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI: openBrowser utility
|
||||
|
||||
Cross-platform browser launcher adapted from Claude Code's `utils/browser.ts`:
|
||||
|
||||
```typescript
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
export async function openBrowser(url: string): Promise<boolean> {
|
||||
// Validate URL
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) return false;
|
||||
|
||||
// Respect BROWSER env var
|
||||
const browserCmd = process.env.BROWSER;
|
||||
|
||||
const cmd = browserCmd
|
||||
? `${browserCmd} ${JSON.stringify(url)}`
|
||||
: process.platform === "darwin"
|
||||
? `open ${JSON.stringify(url)}`
|
||||
: process.platform === "win32"
|
||||
? `rundll32 url.dll,FileProtocolHandler ${JSON.stringify(url)}`
|
||||
: `xdg-open ${JSON.stringify(url)}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI: pairing code
|
||||
|
||||
Short alphanumeric code for visual confirmation between terminal and browser:
|
||||
|
||||
```typescript
|
||||
function generatePairingCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
||||
return Array.from(bytes, b => chars[b % chars.length]).join("");
|
||||
}
|
||||
```
|
||||
|
||||
Excludes ambiguous characters (0/O, 1/l/I) for readability.
|
||||
|
||||
---
|
||||
|
||||
## Config extension
|
||||
|
||||
```typescript
|
||||
// state/config.ts
|
||||
export interface Config {
|
||||
version: 1;
|
||||
meshes: JoinedMesh[];
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
accountId?: string; // NEW: linked dashboard user ID
|
||||
}
|
||||
```
|
||||
|
||||
The `accountId` enables future features:
|
||||
- Re-sync meshes if new ones are created on the dashboard
|
||||
- Show account email in `claudemesh status`
|
||||
- Revoke CLI access from the dashboard
|
||||
|
||||
---
|
||||
|
||||
## DB changes
|
||||
|
||||
### Extend `mesh.member`
|
||||
|
||||
```sql
|
||||
ALTER TABLE mesh.member
|
||||
ADD COLUMN dashboard_user_id TEXT; -- links to Payload CMS user.id
|
||||
|
||||
CREATE INDEX member_dashboard_user_idx
|
||||
ON mesh.member(dashboard_user_id)
|
||||
WHERE dashboard_user_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### No new tables needed
|
||||
|
||||
The sync token is a JWT — stateless, validated by signature. No DB storage
|
||||
required. The broker just reads the claims and creates/finds members.
|
||||
|
||||
JTI dedup is in-memory (Set with TTL eviction matching the JWT expiry).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
| Sync token theft | 15 min TTL, **single-use** (broker tracks used JTIs in memory), localhost-only redirect |
|
||||
| Localhost port scanning | Random port, CORS restricted to `https://claudemesh.com`, `/ping` only returns "ok" |
|
||||
| Reachability check spoofing | Pairing code shown on both terminal and web page — user visually confirms match |
|
||||
| CSRF on /cli-auth | Require existing dashboard session (Google OAuth) |
|
||||
| Multiple CLI devices | Each generates its own keypair — one dashboard user can have multiple CLI identities |
|
||||
| Revoking CLI access | Dashboard can delete `mesh.member` rows linked to a `dashboard_user_id` |
|
||||
| Headless environments | Manual token paste fallback — no browser required |
|
||||
|
||||
---
|
||||
|
||||
## UX flow: first-time experience
|
||||
|
||||
### Happy path (has browser, has meshes)
|
||||
|
||||
```
|
||||
$ npm i -g claudemesh-cli
|
||||
|
||||
$ claudemesh launch --name Alice
|
||||
|
||||
Welcome to claudemesh! No meshes found.
|
||||
Opening browser to sign in...
|
||||
|
||||
Visit: https://claudemesh.com/cli-auth?port=54321&code=A3Kx
|
||||
Or join with invite: claudemesh launch --join <url>
|
||||
|
||||
⣾ Waiting...
|
||||
|
||||
✓ Synced 2 mesh(es): dev-team, research
|
||||
Launching on dev-team (use --mesh to change)
|
||||
|
||||
claudemesh launch — as Alice on dev-team [push]
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
Launching...
|
||||
```
|
||||
|
||||
### Headless path (SSH, no browser)
|
||||
|
||||
```
|
||||
$ claudemesh launch --name Alice
|
||||
|
||||
Welcome to claudemesh! No meshes found.
|
||||
Opening browser to sign in...
|
||||
|
||||
Couldn't open browser automatically.
|
||||
Visit: https://claudemesh.com/cli-auth?port=54321&code=A3Kx
|
||||
Or join with invite: claudemesh launch --join <url>
|
||||
|
||||
Paste sync token: eyJhbGciOi...█
|
||||
|
||||
✓ Synced 1 mesh(es): dev-team
|
||||
|
||||
claudemesh launch — as Alice on dev-team [push]
|
||||
```
|
||||
|
||||
### No meshes on dashboard
|
||||
|
||||
Browser shows "Create a mesh" form. User creates one. Redirects back.
|
||||
|
||||
```
|
||||
✓ Synced 1 mesh(es): my-team (just created)
|
||||
```
|
||||
|
||||
### Second launch (instant, no prompts)
|
||||
|
||||
```
|
||||
$ claudemesh launch --name Alice
|
||||
|
||||
claudemesh launch — as Alice on dev-team [push]
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
Launching...
|
||||
```
|
||||
|
||||
### Customized launch
|
||||
|
||||
```
|
||||
$ claudemesh launch --name Alice --mesh research --role lead --groups eng,review --message-mode inbox
|
||||
|
||||
claudemesh launch — as Alice (lead) on research [@eng:lead, @review] [inbox]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Broker:** `POST /cli-sync` endpoint — validate JWT, JTI dedup, create/find members, return mesh list
|
||||
2. **DB:** Add `dashboard_user_id` to `mesh.member`
|
||||
3. **Web:** `/cli-auth` page — OAuth gate, mesh picker, pairing code display, sync token generation, localhost preflight + redirect, manual token fallback
|
||||
4. **CLI:** `startCallbackListener()` — localhost HTTP server with `/ping` and `/callback`
|
||||
5. **CLI:** `openBrowser()` — cross-platform browser opener
|
||||
6. **CLI:** First-time sync flow in `launch.ts` — no-prompt happy path with race (callback vs paste vs timeout)
|
||||
7. **CLI:** `claudemesh sync` command — standalone re-sync
|
||||
8. **Config:** Add `accountId` field
|
||||
|
||||
---
|
||||
|
||||
## What stays the same
|
||||
|
||||
- `claudemesh join <url>` still works — for users who receive invite links
|
||||
- `claudemesh launch --join <url>` still works — join + launch in one step
|
||||
- Ed25519 keypairs remain the mesh identity — OAuth is only for sync
|
||||
- The broker never sees OAuth tokens — only the sync JWT
|
||||
- Existing users with local meshes are unaffected — sync flow only triggers when `config.meshes` is empty
|
||||
- Interactive prompts (role, groups, mode) still work on subsequent launches without flags
|
||||
|
||||
---
|
||||
|
||||
## Related specs
|
||||
|
||||
- **[Member Profile](member-profile-spec.md)** — Persistent identity
|
||||
(role tag, groups, message mode) on the member row, dashboard
|
||||
management, self-edit permissions, invite presets. The sync spec gets
|
||||
users into the mesh; the member profile spec defines who they are
|
||||
once they're in.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Shared keypair across meshes?** Current spec generates one keypair and
|
||||
uses it for all synced meshes. Simpler, but means revoking one mesh
|
||||
doesn't rotate the key for others. Alternative: one keypair per mesh
|
||||
(more isolation, more config complexity). **Decision: shared for v1.**
|
||||
|
||||
2. **`claudemesh sync --auto`?** Could auto-sync on every `launch` if
|
||||
`accountId` is set (hit broker, check for new meshes). Adds latency to
|
||||
every launch. **Decision: not in v1. Manual `claudemesh sync` only.**
|
||||
663
docs/member-profile-spec.md
Normal file
@@ -0,0 +1,663 @@
|
||||
# Member Profile: Persistent Identity & Dashboard Management
|
||||
|
||||
> Spec for moving member identity (role tag, groups, display name, message
|
||||
> mode) from ephemeral CLI flags to persistent server-side state, editable
|
||||
> from the dashboard with configurable self-edit permissions.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Today, launching a claudemesh session requires re-declaring your identity:
|
||||
|
||||
```bash
|
||||
claudemesh launch --name Alice --role lead --groups eng,review --message-mode push
|
||||
```
|
||||
|
||||
Every. Single. Time. These values live on the ephemeral `presence` row
|
||||
(per-WS connection) and `peerState` row (cross-session, but CLI-written
|
||||
only). There's no way for:
|
||||
|
||||
- An admin to assign someone's role/groups from the dashboard
|
||||
- A user to set their profile once and forget about it
|
||||
- An invite to pre-configure a new member's identity
|
||||
- The dashboard to show/manage who belongs to which groups
|
||||
|
||||
This creates friction for daily users and makes managed teams impossible.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Move identity to `member` (persistent, server-side)
|
||||
|
||||
| Field | Current location | New location | Source of truth |
|
||||
|---|---|---|---|
|
||||
| `displayName` | presence (ephemeral) | **member** (persistent) | Server, CLI flag overrides per-session |
|
||||
| `roleTag` | nowhere (CLI `--role` flag only) | **member** (persistent) | Server, CLI flag overrides per-session |
|
||||
| `groups` | peerState (CLI-written) | **member** (persistent) | Server, CLI flag overrides per-session |
|
||||
| `messageMode` | config.json (local file) | **member** (persistent) | Server, CLI flag overrides per-session |
|
||||
| `status` | presence | presence (no change) | Ephemeral, changes per-minute |
|
||||
| `summary` | presence | presence (no change) | Ephemeral, changes per-task |
|
||||
| `cwd`, `pid` | presence | presence (no change) | Literal session metadata |
|
||||
|
||||
### Three-layer model
|
||||
|
||||
```
|
||||
member (persistent, server-side)
|
||||
│ Source of truth for identity. Set via dashboard, CLI profile command,
|
||||
│ or invite presets. Survives everything.
|
||||
│
|
||||
├── peerState (cross-session, server-side)
|
||||
│ Cumulative stats, visibility toggle, last-seen metadata.
|
||||
│ Still CLI-written. Not promoted — these are operational, not identity.
|
||||
│
|
||||
└── presence (ephemeral, per-connection)
|
||||
Runtime snapshot. Copies member defaults on connect.
|
||||
CLI flags override for this session only.
|
||||
Status, summary, cwd, pid — all transient.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema changes
|
||||
|
||||
### Extend `mesh.member`
|
||||
|
||||
```sql
|
||||
ALTER TABLE mesh.member
|
||||
ADD COLUMN role_tag TEXT, -- free-text label (lead, backend-dev, observer)
|
||||
ADD COLUMN default_groups JSONB DEFAULT '[]', -- [{name: string, role?: string}]
|
||||
ADD COLUMN message_mode TEXT DEFAULT 'push', -- push | inbox | off
|
||||
ADD COLUMN dashboard_user_id TEXT; -- links to Payload CMS user.id (for CLI sync)
|
||||
|
||||
CREATE INDEX member_dashboard_user_idx
|
||||
ON mesh.member(dashboard_user_id)
|
||||
WHERE dashboard_user_id IS NOT NULL;
|
||||
```
|
||||
|
||||
**Note:** `member.displayName` already exists. `member.role` stays as the
|
||||
permission level enum (admin/member). `role_tag` is the new free-text label.
|
||||
|
||||
### Rename for clarity
|
||||
|
||||
The existing `member.role` (admin/member enum) controls **permissions**.
|
||||
The new `member.role_tag` is a **label** visible to peers. To avoid
|
||||
confusion in code and UI:
|
||||
|
||||
```
|
||||
member.role → member.permission -- "admin" | "member" (access control)
|
||||
member.role_tag → member.roleTag -- "backend-dev", "lead", etc. (display label)
|
||||
```
|
||||
|
||||
**DB migration:** rename the column for clarity:
|
||||
|
||||
```sql
|
||||
ALTER TABLE mesh.member RENAME COLUMN role TO permission;
|
||||
-- Also rename the enum type if feasible, or keep as-is (DB enum name is internal)
|
||||
```
|
||||
|
||||
**Impact:** Update all broker code that references `member.role` to
|
||||
`member.permission`. The `meshRoleEnum` values stay the same (admin/member).
|
||||
|
||||
### Extend `mesh.mesh` — self-edit policy
|
||||
|
||||
```sql
|
||||
ALTER TABLE mesh.mesh
|
||||
ADD COLUMN self_editable JSONB DEFAULT '{
|
||||
"displayName": true,
|
||||
"roleTag": true,
|
||||
"groups": true,
|
||||
"messageMode": true
|
||||
}';
|
||||
```
|
||||
|
||||
Controls what members can edit about themselves. Admins can always edit
|
||||
anyone. Mesh creator configures this on the dashboard.
|
||||
|
||||
### Extend `mesh.invite` — presets
|
||||
|
||||
```sql
|
||||
ALTER TABLE mesh.invite
|
||||
ADD COLUMN preset JSONB DEFAULT '{}';
|
||||
```
|
||||
|
||||
Preset schema:
|
||||
|
||||
```typescript
|
||||
interface InvitePreset {
|
||||
displayName?: string; // rarely set — joiner usually picks their own
|
||||
roleTag?: string; // "backend-dev", "observer", etc.
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
```
|
||||
|
||||
When a member joins via this invite, preset values are applied to the
|
||||
member row as defaults. The joiner can change them later (if self-editable).
|
||||
|
||||
---
|
||||
|
||||
## Permission model
|
||||
|
||||
### Who can edit what
|
||||
|
||||
| Action | Who | Condition |
|
||||
|---|---|---|
|
||||
| Edit your own `displayName` | You | `mesh.selfEditable.displayName` is true |
|
||||
| Edit your own `roleTag` | You | `mesh.selfEditable.roleTag` is true |
|
||||
| Edit your own `groups` | You | `mesh.selfEditable.groups` is true |
|
||||
| Edit your own `messageMode` | You | `mesh.selfEditable.messageMode` is true |
|
||||
| Edit **any member's** profile fields | Mesh admins | Always |
|
||||
| Change `permission` (admin ↔ member) | Mesh admins | Always |
|
||||
| Revoke a member | Mesh admins | Always |
|
||||
| Change `selfEditable` policy | Mesh admins | Always |
|
||||
|
||||
### Default policy by tier
|
||||
|
||||
| Field | free | pro | team | enterprise |
|
||||
|---|---|---|---|---|
|
||||
| `displayName` | self | self | self | self |
|
||||
| `roleTag` | self | self | admin-only | admin-only |
|
||||
| `groups` | self | self | admin-only | admin-only |
|
||||
| `messageMode` | self | self | self | self |
|
||||
|
||||
These are defaults — the mesh creator can override any of them on the
|
||||
dashboard regardless of tier.
|
||||
|
||||
---
|
||||
|
||||
## Broker changes
|
||||
|
||||
### New HTTP endpoints
|
||||
|
||||
#### `PATCH /mesh/:meshId/member/:memberId`
|
||||
|
||||
Update a member's profile fields. Used by dashboard and CLI.
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
PATCH /mesh/:meshId/member/:memberId
|
||||
Authorization: Bearer <dashboard-session-token> OR X-Pubkey + X-Signature
|
||||
{
|
||||
"displayName": "Alice",
|
||||
"roleTag": "lead",
|
||||
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
|
||||
"messageMode": "push"
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"ok": true,
|
||||
"member": {
|
||||
"id": "member_123",
|
||||
"displayName": "Alice",
|
||||
"roleTag": "lead",
|
||||
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
|
||||
"messageMode": "push",
|
||||
"permission": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Authorization logic:**
|
||||
|
||||
```
|
||||
if (caller is dashboard admin OR caller.memberId == targetMemberId with admin permission):
|
||||
→ allow all fields
|
||||
elif (caller.memberId == targetMemberId):
|
||||
→ check mesh.selfEditable for each field
|
||||
→ reject fields that are admin-only: 403 "field X is admin-managed in this mesh"
|
||||
else:
|
||||
→ 403 "not authorized"
|
||||
```
|
||||
|
||||
**Side effect:** If the target member has active WebSocket connections,
|
||||
push a `profile_updated` event to all their sessions:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "profile_updated",
|
||||
"memberId": "member_123",
|
||||
"changes": {
|
||||
"roleTag": "lead",
|
||||
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The CLI handles this by updating its in-memory state for the current session.
|
||||
|
||||
#### `GET /mesh/:meshId/members`
|
||||
|
||||
List all members with their profiles. Used by dashboard and CLI.
|
||||
|
||||
```typescript
|
||||
// Response
|
||||
{
|
||||
"ok": true,
|
||||
"members": [
|
||||
{
|
||||
"id": "member_123",
|
||||
"displayName": "Alice",
|
||||
"roleTag": "lead",
|
||||
"groups": [{ "name": "eng", "role": "lead" }],
|
||||
"messageMode": "push",
|
||||
"permission": "admin",
|
||||
"dashboardUserId": "user_abc123",
|
||||
"joinedAt": "2026-04-01T10:00:00Z",
|
||||
"lastSeenAt": "2026-04-08T14:30:00Z",
|
||||
"online": true,
|
||||
"sessionCount": 2
|
||||
},
|
||||
{
|
||||
"id": "member_456",
|
||||
"displayName": "Bob",
|
||||
"roleTag": "backend-dev",
|
||||
"groups": [{ "name": "eng" }],
|
||||
"messageMode": "inbox",
|
||||
"permission": "member",
|
||||
"dashboardUserId": null,
|
||||
"joinedAt": "2026-04-03T09:00:00Z",
|
||||
"lastSeenAt": "2026-04-07T18:00:00Z",
|
||||
"online": false,
|
||||
"sessionCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`online` and `sessionCount` are derived from active `presence` rows
|
||||
(disconnectedAt IS NULL) for each member.
|
||||
|
||||
#### `PATCH /mesh/:meshId/settings`
|
||||
|
||||
Update mesh settings including self-edit policy. Dashboard only, admin only.
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
PATCH /mesh/:meshId/settings
|
||||
{
|
||||
"selfEditable": {
|
||||
"displayName": true,
|
||||
"roleTag": false,
|
||||
"groups": false,
|
||||
"messageMode": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### hello_ack changes
|
||||
|
||||
When a peer connects, the `hello_ack` now includes the member's persistent
|
||||
profile so the CLI can apply defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello_ack",
|
||||
"presenceId": "pres_789",
|
||||
"memberDisplayName": "Alice",
|
||||
"memberProfile": {
|
||||
"roleTag": "lead",
|
||||
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
|
||||
"messageMode": "push"
|
||||
},
|
||||
"meshPolicy": {
|
||||
"selfEditable": { "displayName": true, "roleTag": false, "groups": false, "messageMode": true }
|
||||
},
|
||||
"restored": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Presence creation changes
|
||||
|
||||
When creating a `presence` row on hello, the broker now merges:
|
||||
|
||||
```
|
||||
1. Start with member defaults (displayName, roleTag → groups, messageMode)
|
||||
2. Override with CLI hello payload (if flags were provided)
|
||||
3. Write to presence row
|
||||
```
|
||||
|
||||
This means `presence.groups` is populated from `member.default_groups` if
|
||||
the CLI didn't send explicit groups in the hello. No more blank sessions.
|
||||
|
||||
### Join flow changes
|
||||
|
||||
When a member joins via `/join`, the broker applies invite presets:
|
||||
|
||||
```typescript
|
||||
// In handleJoinPost, after creating the member row:
|
||||
if (invite.preset) {
|
||||
const preset = invite.preset;
|
||||
await db.update(meshMember)
|
||||
.set({
|
||||
roleTag: preset.roleTag ?? null,
|
||||
defaultGroups: preset.groups ?? [],
|
||||
messageMode: preset.messageMode ?? "push",
|
||||
// displayName already set from the join request
|
||||
})
|
||||
.where(eq(meshMember.id, newMemberId));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI changes
|
||||
|
||||
### Launch flow (simplified)
|
||||
|
||||
```typescript
|
||||
// After config loaded and mesh selected:
|
||||
|
||||
// 1. Connect to broker (existing flow)
|
||||
// 2. Receive hello_ack with memberProfile + meshPolicy
|
||||
|
||||
// 3. Apply member defaults, CLI flags override
|
||||
const effectiveName = flags.name ?? helloAck.memberDisplayName;
|
||||
const effectiveRole = flags.role ?? helloAck.memberProfile.roleTag;
|
||||
const effectiveGroups = flags.groups ?? helloAck.memberProfile.groups;
|
||||
const effectiveMode = flags.messageMode ?? helloAck.memberProfile.messageMode;
|
||||
|
||||
// 4. No prompts. Flags or server defaults. Done.
|
||||
```
|
||||
|
||||
### `claudemesh profile` command
|
||||
|
||||
New command to view/edit your member profile from the CLI:
|
||||
|
||||
```bash
|
||||
# View current profile
|
||||
claudemesh profile
|
||||
# Name: Alice
|
||||
# Role: lead
|
||||
# Groups: eng (lead), review
|
||||
# Messages: push
|
||||
# Mesh: dev-team (admin)
|
||||
|
||||
# Edit fields (sends PATCH to broker)
|
||||
claudemesh profile --role-tag fullstack
|
||||
claudemesh profile --groups eng,frontend,review
|
||||
claudemesh profile --message-mode inbox
|
||||
claudemesh profile --name "Alice M."
|
||||
|
||||
# Edit another member (admin only)
|
||||
claudemesh profile --member Bob --role-tag junior-dev --groups onboarding
|
||||
```
|
||||
|
||||
Fields that are admin-managed show a lock icon:
|
||||
|
||||
```bash
|
||||
claudemesh profile
|
||||
# Name: Alice
|
||||
# Role: lead 🔒 (admin-managed)
|
||||
# Groups: eng (lead), review 🔒 (admin-managed)
|
||||
# Messages: push
|
||||
```
|
||||
|
||||
Attempting to edit a locked field:
|
||||
|
||||
```bash
|
||||
claudemesh profile --role-tag senior
|
||||
# Error: roleTag is admin-managed in this mesh. Ask a mesh admin to change it.
|
||||
```
|
||||
|
||||
### First launch stores displayName
|
||||
|
||||
When `--name Alice` is provided on first launch (or sync), the CLI sends
|
||||
it to the broker which persists it on the member row. Future launches
|
||||
don't need `--name`:
|
||||
|
||||
```bash
|
||||
# First time
|
||||
claudemesh launch --name Alice
|
||||
# → broker stores displayName="Alice" on member row
|
||||
|
||||
# Every subsequent launch
|
||||
claudemesh launch
|
||||
# → hello_ack returns displayName="Alice", no flag needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invite presets
|
||||
|
||||
### Creating an invite with presets (dashboard)
|
||||
|
||||
```
|
||||
Create invite link — dev-team
|
||||
|
||||
Permission: [member ▾] (admin/member)
|
||||
|
||||
Profile presets (applied to new members):
|
||||
Role tag: [backend-dev ]
|
||||
Groups: [eng ×] [review ×] [+ Add]
|
||||
Message mode: (●) Push ( ) Inbox ( ) Off
|
||||
|
||||
Link settings:
|
||||
Max uses: [10 ]
|
||||
Expires: [7 days ▾]
|
||||
|
||||
[Generate link]
|
||||
|
||||
────────────────────
|
||||
|
||||
ic://join/eyJhbGciOi...
|
||||
https://claudemesh.com/join/eyJhbGciOi...
|
||||
|
||||
[Copy link]
|
||||
```
|
||||
|
||||
### Creating an invite with presets (CLI)
|
||||
|
||||
```bash
|
||||
claudemesh invite create \
|
||||
--role-tag backend-dev \
|
||||
--groups eng,review \
|
||||
--message-mode push \
|
||||
--max-uses 10 \
|
||||
--expires 7d
|
||||
```
|
||||
|
||||
### Invite payload extension
|
||||
|
||||
The signed invite payload gains a `preset` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"mesh_id": "mesh_xyz",
|
||||
"mesh_slug": "dev-team",
|
||||
"broker_url": "wss://ic.claudemesh.com/ws",
|
||||
"expires_at": 1713100000,
|
||||
"mesh_root_key": "...",
|
||||
"role": "member",
|
||||
"preset": {
|
||||
"roleTag": "backend-dev",
|
||||
"groups": [{ "name": "eng" }, { "name": "review" }],
|
||||
"messageMode": "push"
|
||||
},
|
||||
"owner_pubkey": "...",
|
||||
"signature": "..."
|
||||
}
|
||||
```
|
||||
|
||||
The `preset` is included in the canonical signed bytes (appended to
|
||||
the existing canonical format) so it can't be tampered with.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard views
|
||||
|
||||
### Mesh members page
|
||||
|
||||
```
|
||||
dev-team — Members
|
||||
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Name Role tag Groups Status Access │
|
||||
│─────────────────────────────────────────────────────────────── │
|
||||
│ ● Alice lead eng, review idle admin ▾ │
|
||||
│ ● Bob backend-dev eng working member ▾ │
|
||||
│ ○ Carol designer design, ux — member ▾ │
|
||||
│ ○ Dave — — — member ▾ │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
|
||||
● = online (has active session) ○ = offline
|
||||
|
||||
[Invite member]
|
||||
```
|
||||
|
||||
Clicking a member opens an edit panel:
|
||||
|
||||
```
|
||||
Edit member — Bob
|
||||
|
||||
Display name: [Bob ]
|
||||
Role tag: [backend-dev ]
|
||||
Groups: [eng ×] [+ Add]
|
||||
Message mode: (●) Push ( ) Inbox ( ) Off
|
||||
Permission: [member ▾]
|
||||
|
||||
Joined: Apr 3, 2026
|
||||
Last seen: 2 hours ago
|
||||
Sessions: 0 (offline)
|
||||
|
||||
[Save] [Revoke access]
|
||||
```
|
||||
|
||||
### Mesh settings page
|
||||
|
||||
```
|
||||
dev-team — Settings
|
||||
|
||||
General:
|
||||
Name: [dev-team ]
|
||||
Visibility: [private ▾]
|
||||
|
||||
Member self-edit permissions:
|
||||
What can members edit about themselves?
|
||||
|
||||
Display name: [✓]
|
||||
Role tag: [ ] ← only admins can assign
|
||||
Groups: [ ] ← only admins can assign
|
||||
Message mode: [✓]
|
||||
|
||||
[Save]
|
||||
```
|
||||
|
||||
### Live presence view
|
||||
|
||||
```
|
||||
dev-team — Live
|
||||
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ ● Alice (lead) idle │
|
||||
│ eng (lead), review │
|
||||
│ Session 1: ~/Desktop/claudemesh — "Working on auth sync" │
|
||||
│ Session 2: ~/Desktop/cuidecar — "Reviewing PR #47" │
|
||||
│ │
|
||||
│ ● Bob (backend-dev) working │
|
||||
│ eng │
|
||||
│ Session 1: ~/Desktop/api — "Fixing migration bug" │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Auto-refreshes every 5s via WebSocket.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-time profile push
|
||||
|
||||
When an admin (or self) updates a member's profile via the dashboard or
|
||||
CLI, all active sessions for that member receive a push:
|
||||
|
||||
```
|
||||
Dashboard: admin changes Bob's groups
|
||||
→ PATCH /mesh/:meshId/member/:memberId { groups: [{name: "ops"}] }
|
||||
→ Broker updates member row
|
||||
→ Broker finds all active presence rows for this memberId
|
||||
→ Broker sends to each WS connection:
|
||||
{ type: "profile_updated", changes: { groups: [{name: "ops"}] } }
|
||||
→ Bob's CLI receives push, updates in-memory groups
|
||||
→ Bob's next list_peers / join_group reflects the change
|
||||
→ No restart needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from peerState
|
||||
|
||||
The existing `peerState` table stores `groups`, `profile`, `visible`,
|
||||
`lastDisplayName`, and `cumulativeStats`. After this change:
|
||||
|
||||
| peerState field | Migration |
|
||||
|---|---|
|
||||
| `groups` | Copy to `member.default_groups` for existing members. peerState.groups becomes a session-level overlay (for CLI `join_group`/`leave_group` within a session). |
|
||||
| `lastDisplayName` | Already on `member.displayName`. Drop from peerState. |
|
||||
| `profile` (avatar, title, bio) | Keep on peerState for now. These are presentation, not identity. Could move to member later. |
|
||||
| `visible` | Keep on peerState. Session-scoped toggle. |
|
||||
| `cumulativeStats` | Keep on peerState. Operational data, not identity. |
|
||||
|
||||
**The peerState table is NOT removed.** It still serves its purpose for
|
||||
cross-session operational state. The member table absorbs identity fields
|
||||
only.
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **DB migration:** Add columns to `member` (role_tag, default_groups,
|
||||
message_mode, dashboard_user_id), `mesh` (self_editable), `invite`
|
||||
(preset). Rename `member.role` → `member.permission`.
|
||||
2. **Broker:** `PATCH /mesh/:meshId/member/:memberId` endpoint with
|
||||
self-edit permission checks and real-time push.
|
||||
3. **Broker:** `GET /mesh/:meshId/members` endpoint with online status.
|
||||
4. **Broker:** `PATCH /mesh/:meshId/settings` endpoint.
|
||||
5. **Broker:** Update `handleHello` to include memberProfile + meshPolicy
|
||||
in hello_ack. Update presence creation to merge member defaults.
|
||||
6. **Broker:** Update `/join` to apply invite presets to new members.
|
||||
7. **CLI:** Update launch to read memberProfile from hello_ack, skip
|
||||
prompts when server has defaults, flags override.
|
||||
8. **CLI:** `claudemesh profile` command.
|
||||
9. **CLI:** Update invite creation to accept preset flags.
|
||||
10. **Web:** Member management page (list, edit, revoke).
|
||||
11. **Web:** Mesh settings page (self-edit policy).
|
||||
12. **Web:** Invite creation with presets.
|
||||
13. **Web:** Live presence view.
|
||||
|
||||
---
|
||||
|
||||
## What stays the same
|
||||
|
||||
- Ed25519 keypairs remain the mesh identity
|
||||
- E2E encryption unchanged (crypto_box with peer keys)
|
||||
- `presence` table stays ephemeral — status, summary, cwd, pid
|
||||
- `peerState` keeps operational data — stats, visibility, session profile
|
||||
- `list_peers` MCP tool still works (reads from presence, now enriched
|
||||
with member defaults)
|
||||
- CLI `--role`, `--groups`, `--message-mode` flags still work as
|
||||
per-session overrides
|
||||
- `join_group` / `leave_group` WS messages still work for session-scoped
|
||||
group changes (these update presence, not member)
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Session-scoped group changes vs member-level groups.** If member has
|
||||
`groups: [eng]` and the CLI does `join_group("review")` mid-session,
|
||||
does that add to the member row or just the presence? **Proposal: just
|
||||
presence.** Session-scoped join/leave is temporary. Use `claudemesh
|
||||
profile --groups` or dashboard for permanent changes.
|
||||
|
||||
2. **Profile conflicts across devices.** If Alice has two CLI devices with
|
||||
different keypairs (different member rows), they have independent
|
||||
profiles. This is correct — they're different identities in the mesh.
|
||||
But if she syncs from the same dashboard account, should her profile
|
||||
sync across devices? **Proposal: no, not in v1.** Each member row is
|
||||
independent. Dashboard shows all members linked to your account.
|
||||
|
||||
3. **Audit trail for profile changes.** Should profile edits go in the
|
||||
audit log? **Proposal: yes.** Event type: `member_profile_updated`,
|
||||
payload includes who changed what. Useful for managed teams.
|
||||
1258
docs/mesh-services-spec.md
Normal file
306
docs/protocol.md
@@ -15,14 +15,86 @@ leaves the peer.
|
||||
|
||||
All broker ↔ peer traffic is line-delimited JSON on a single WebSocket.
|
||||
|
||||
| Type | Direction | Purpose |
|
||||
|--------------|---------------|----------------------------------------------------|
|
||||
| `hello` | peer → broker | signed handshake — proves control of ed25519 key |
|
||||
| `hello_ack` | broker → peer | confirms identity + returns current mesh presence |
|
||||
| `send` | peer → broker | ciphertext envelope addressed to one or more peers |
|
||||
| `ack` | broker → peer | broker-side delivery receipt for a `send` |
|
||||
| `push` | broker → peer | an inbound envelope the broker is forwarding |
|
||||
| `error` | broker → peer | handshake or authorization failure |
|
||||
| Type | Direction | Purpose |
|
||||
|------------------------|---------------|----------------------------------------------------|
|
||||
| `hello` | peer → broker | signed handshake — proves control of ed25519 key |
|
||||
| `hello_ack` | broker → peer | confirms identity + returns current mesh presence |
|
||||
| `send` | peer → broker | ciphertext envelope addressed to one or more peers |
|
||||
| `ack` | broker → peer | broker-side delivery receipt for a `send` |
|
||||
| `push` | broker → peer | an inbound envelope the broker is forwarding |
|
||||
| `set_status` | peer → broker | manual status override (idle, working, dnd) |
|
||||
| `set_summary` | peer → broker | update the session's human-readable summary |
|
||||
| `list_peers` | peer → broker | request connected peers in the same mesh |
|
||||
| `peers_list` | broker → peer | response to `list_peers` |
|
||||
| `join_group` | peer → broker | join a named group with optional role |
|
||||
| `leave_group` | peer → broker | leave a named group |
|
||||
| `set_state` | peer → broker | write a shared key-value pair |
|
||||
| `get_state` | peer → broker | read a shared state key |
|
||||
| `list_state` | peer → broker | list all shared state entries |
|
||||
| `state_change` | broker → peer | a state key was changed by another peer |
|
||||
| `state_result` | broker → peer | response to `get_state` |
|
||||
| `state_list` | broker → peer | response to `list_state` |
|
||||
| `remember` | peer → broker | store a persistent memory |
|
||||
| `recall` | peer → broker | full-text search over memories |
|
||||
| `forget` | peer → broker | soft-delete a memory |
|
||||
| `memory_stored` | broker → peer | acknowledgement for `remember` |
|
||||
| `memory_results` | broker → peer | response to `recall` |
|
||||
| `message_status` | peer → broker | check delivery status of a sent message |
|
||||
| `message_status_result`| broker → peer | per-recipient delivery detail |
|
||||
| `share_context` | peer → broker | share current working context |
|
||||
| `get_context` | peer → broker | search shared contexts by query |
|
||||
| `list_contexts` | peer → broker | list all shared contexts |
|
||||
| `context_shared` | broker → peer | acknowledgement for `share_context` |
|
||||
| `context_results` | broker → peer | response to `get_context` |
|
||||
| `context_list` | broker → peer | response to `list_contexts` |
|
||||
| `create_task` | peer → broker | create a task |
|
||||
| `claim_task` | peer → broker | claim an open task |
|
||||
| `complete_task` | peer → broker | mark a task as done |
|
||||
| `list_tasks` | peer → broker | list tasks with optional filters |
|
||||
| `task_created` | broker → peer | acknowledgement for `create_task` |
|
||||
| `task_list` | broker → peer | response to task queries |
|
||||
| `vector_store` | peer → broker | store a document in a vector collection |
|
||||
| `vector_search` | peer → broker | search a vector collection |
|
||||
| `vector_delete` | peer → broker | delete a point from a vector collection |
|
||||
| `list_collections` | peer → broker | list all vector collections |
|
||||
| `vector_stored` | broker → peer | acknowledgement for `vector_store` |
|
||||
| `vector_results` | broker → peer | response to `vector_search` |
|
||||
| `collection_list` | broker → peer | response to `list_collections` |
|
||||
| `graph_query` | peer → broker | run a read-only Cypher query |
|
||||
| `graph_execute` | peer → broker | run a write Cypher statement |
|
||||
| `graph_result` | broker → peer | response to graph queries |
|
||||
| `mesh_query` | peer → broker | run a SELECT in the mesh's schema |
|
||||
| `mesh_execute` | peer → broker | run DDL/DML in the mesh's schema |
|
||||
| `mesh_schema` | peer → broker | list tables and columns in the mesh's schema |
|
||||
| `mesh_query_result` | broker → peer | response to `mesh_query` |
|
||||
| `mesh_schema_result` | broker → peer | response to `mesh_schema` |
|
||||
| `mesh_info` | peer → broker | request full mesh overview |
|
||||
| `mesh_info_result` | broker → peer | aggregated mesh overview |
|
||||
| `create_stream` | peer → broker | create a named real-time stream |
|
||||
| `publish` | peer → broker | publish data to a stream |
|
||||
| `subscribe` | peer → broker | subscribe to a stream |
|
||||
| `unsubscribe` | peer → broker | unsubscribe from a stream |
|
||||
| `list_streams` | peer → broker | list all streams in the mesh |
|
||||
| `stream_created` | broker → peer | acknowledgement for `create_stream` |
|
||||
| `stream_data` | broker → peer | real-time data pushed from a stream |
|
||||
| `subscribed` | broker → peer | confirmation of stream subscription |
|
||||
| `stream_list` | broker → peer | response to `list_streams` |
|
||||
| `schedule` | peer → broker | schedule a message for future or recurring delivery|
|
||||
| `list_scheduled` | peer → broker | list pending scheduled messages |
|
||||
| `cancel_scheduled` | peer → broker | cancel a scheduled message by id |
|
||||
| `scheduled_ack` | broker → peer | acknowledgement for `schedule` |
|
||||
| `scheduled_list` | broker → peer | response to `list_scheduled` |
|
||||
| `cancel_scheduled_ack` | broker → peer | confirmation of cancellation |
|
||||
| `get_file` | peer → broker | request a presigned download URL |
|
||||
| `list_files` | peer → broker | list files in the mesh |
|
||||
| `file_status` | peer → broker | get access log for a file |
|
||||
| `delete_file` | peer → broker | soft-delete a file |
|
||||
| `grant_file_access` | peer → broker | grant a peer access to an encrypted file |
|
||||
| `file_url` | broker → peer | presigned download URL |
|
||||
| `file_list` | broker → peer | response to `list_files` |
|
||||
| `file_status_result` | broker → peer | access log for a file |
|
||||
| `grant_file_access_ok` | broker → peer | acknowledgement for `grant_file_access` |
|
||||
| `error` | broker → peer | structured error (handshake, auth, or runtime) |
|
||||
|
||||
Each message carries a monotonic `seq`, a mesh id, and the sender's
|
||||
public key fingerprint. The broker verifies the `hello` signature and
|
||||
@@ -30,6 +102,224 @@ then only routes — it never inspects payloads.
|
||||
|
||||
---
|
||||
|
||||
## Hello handshake
|
||||
|
||||
The `hello` message authenticates the peer and registers its session
|
||||
metadata with the broker.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "hello",
|
||||
"meshId": "acme-payments",
|
||||
"memberId": "m_abc123",
|
||||
"pubkey": "<ed25519 hex>",
|
||||
"sessionPubkey": "<ephemeral ed25519 hex>", // optional
|
||||
"displayName": "Mou", // optional
|
||||
"sessionId": "w1t0p0",
|
||||
"pid": 42781,
|
||||
"cwd": "/home/user/project",
|
||||
"peerType": "ai", // "ai" | "human" | "connector"
|
||||
"channel": "claude-code", // e.g. "claude-code", "telegram", "slack", "web"
|
||||
"model": "opus-4", // AI model identifier
|
||||
"groups": [{ "name": "backend", "role": "lead" }],
|
||||
"timestamp": 1717459200000,
|
||||
"signature": "<ed25519 hex>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------------|-----------------------------------|----------|---------------------------------------------------------|
|
||||
| `meshId` | `string` | yes | Mesh slug |
|
||||
| `memberId` | `string` | yes | Member id from enrollment |
|
||||
| `pubkey` | `string` | yes | ed25519 public key (hex), must match `mesh.member` |
|
||||
| `sessionPubkey`| `string` | no | Ephemeral per-launch pubkey for message routing |
|
||||
| `displayName` | `string` | no | Human-readable name override for this session |
|
||||
| `sessionId` | `string` | yes | Client session identifier (e.g. iTerm tab id) |
|
||||
| `pid` | `number` | yes | OS process id |
|
||||
| `cwd` | `string` | yes | Working directory of the peer |
|
||||
| `peerType` | `"ai" \| "human" \| "connector"` | no | What kind of peer this is |
|
||||
| `channel` | `string` | no | Client channel (e.g. `"claude-code"`, `"slack"`, `"web"`) |
|
||||
| `model` | `string` | no | AI model identifier (e.g. `"opus-4"`, `"sonnet-4"`) |
|
||||
| `groups` | `Array<{name, role?}>` | no | Groups to join on connect |
|
||||
| `timestamp` | `number` | yes | ms epoch; broker rejects if outside ±60 s of its clock |
|
||||
| `signature` | `string` | yes | ed25519 signature over `${meshId}\|${memberId}\|${pubkey}\|${timestamp}` |
|
||||
|
||||
---
|
||||
|
||||
## Peer list
|
||||
|
||||
The `peers_list` response includes session metadata for each connected
|
||||
peer, mirroring the fields sent in `hello`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "peers_list",
|
||||
"peers": [
|
||||
{
|
||||
"pubkey": "<ed25519 hex>",
|
||||
"displayName": "Mou",
|
||||
"status": "working",
|
||||
"summary": "Refactoring the scheduler",
|
||||
"groups": [{ "name": "backend", "role": "lead" }],
|
||||
"sessionId": "w1t0p0",
|
||||
"connectedAt": "2025-06-04T10:30:00Z",
|
||||
"cwd": "/home/user/project",
|
||||
"peerType": "ai",
|
||||
"channel": "claude-code",
|
||||
"model": "opus-4"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|-----------------------------------|----------|----------------------------------------------|
|
||||
| `pubkey` | `string` | yes | Peer's ed25519 public key (hex) |
|
||||
| `displayName` | `string` | yes | Human-readable name |
|
||||
| `status` | `PeerStatus` | yes | `"idle"`, `"working"`, or `"dnd"` |
|
||||
| `summary` | `string \| null` | yes | Session summary set by the peer |
|
||||
| `groups` | `Array<{name, role?}>` | yes | Groups the peer belongs to |
|
||||
| `sessionId` | `string` | yes | Client session identifier |
|
||||
| `connectedAt` | `string` | yes | ISO 8601 timestamp |
|
||||
| `cwd` | `string` | no | Working directory |
|
||||
| `peerType` | `"ai" \| "human" \| "connector"` | no | Peer kind |
|
||||
| `channel` | `string` | no | Client channel |
|
||||
| `model` | `string` | no | AI model identifier |
|
||||
|
||||
---
|
||||
|
||||
## System notifications
|
||||
|
||||
The broker broadcasts topology events as `push` messages with
|
||||
`subtype: "system"`. These are not encrypted — the broker generates
|
||||
them directly.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "push",
|
||||
"messageId": "msg_xyz",
|
||||
"meshId": "acme-payments",
|
||||
"senderPubkey": "<broker pubkey>",
|
||||
"priority": "low",
|
||||
"nonce": "",
|
||||
"ciphertext": "",
|
||||
"createdAt": "2025-06-04T10:30:00Z",
|
||||
"subtype": "system",
|
||||
"event": "peer_joined",
|
||||
"eventData": {
|
||||
"pubkey": "<ed25519 hex>",
|
||||
"displayName": "Mou",
|
||||
"peerType": "ai"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------------|----------------------------|----------|----------------------------------------------------|
|
||||
| `subtype` | `"reminder" \| "system"` | no | `"system"` for topology events, `"reminder"` for scheduled deliveries |
|
||||
| `event` | `string` | no | Machine-readable event name (e.g. `"peer_joined"`, `"peer_left"`) |
|
||||
| `eventData` | `Record<string, unknown>` | no | Structured payload for the event |
|
||||
|
||||
The standard `push` fields (`messageId`, `meshId`, `senderPubkey`,
|
||||
`priority`, `nonce`, `ciphertext`, `createdAt`) are always present.
|
||||
For system notifications, `nonce` and `ciphertext` are empty strings.
|
||||
|
||||
---
|
||||
|
||||
## Scheduled messages
|
||||
|
||||
Peers can schedule one-shot or recurring messages for future delivery.
|
||||
When a scheduled message fires, the recipient receives a standard
|
||||
`push` with `subtype: "reminder"`.
|
||||
|
||||
### `schedule` (peer → broker)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "schedule",
|
||||
"to": "<pubkey or display name>",
|
||||
"message": "Stand-up in 5 minutes",
|
||||
"deliverAt": 1717459200000,
|
||||
"subtype": "reminder",
|
||||
"cron": "0 9 * * 1-5",
|
||||
"recurring": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------------|--------------|----------|------------------------------------------------------------------|
|
||||
| `to` | `string` | yes | Recipient — member pubkey or display name |
|
||||
| `message` | `string` | yes | Plaintext message body |
|
||||
| `deliverAt` | `number` | yes | Unix timestamp (ms). Ignored when `cron` is set. |
|
||||
| `subtype` | `"reminder"` | no | Semantic tag — surfaces differently to the receiver |
|
||||
| `cron` | `string` | no | Standard 5-field cron expression for recurring delivery |
|
||||
| `recurring` | `boolean` | no | Whether this is a recurring schedule. Implied `true` when `cron` is set. |
|
||||
|
||||
### `scheduled_ack` (broker → peer)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "scheduled_ack",
|
||||
"scheduledId": "sched_abc",
|
||||
"deliverAt": 1717459200000,
|
||||
"cron": "0 9 * * 1-5"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|-------------------------------------------|
|
||||
| `scheduledId` | `string` | yes | Assigned id for the scheduled entry |
|
||||
| `deliverAt` | `number` | yes | Resolved delivery time (ms epoch) |
|
||||
| `cron` | `string` | no | Echoed cron expression for recurring entries |
|
||||
|
||||
### `list_scheduled` (peer → broker)
|
||||
|
||||
No payload fields beyond `type`.
|
||||
|
||||
### `scheduled_list` (broker → peer)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "scheduled_list",
|
||||
"messages": [
|
||||
{
|
||||
"id": "sched_abc",
|
||||
"to": "<pubkey>",
|
||||
"message": "Stand-up in 5 minutes",
|
||||
"deliverAt": 1717459200000,
|
||||
"createdAt": 1717372800000,
|
||||
"cron": "0 9 * * 1-5",
|
||||
"firedCount": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|----------|----------|-----------------------------------------------|
|
||||
| `id` | `string` | yes | Scheduled entry id |
|
||||
| `to` | `string` | yes | Recipient |
|
||||
| `message` | `string` | yes | Message body |
|
||||
| `deliverAt` | `number` | yes | Next delivery time (ms epoch) |
|
||||
| `createdAt` | `number` | yes | When the entry was created (ms epoch) |
|
||||
| `cron` | `string` | no | Cron expression, present for recurring entries|
|
||||
| `firedCount` | `number` | no | Times the cron entry has fired so far |
|
||||
|
||||
### `cancel_scheduled` (peer → broker)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|-----------------------------|
|
||||
| `scheduledId` | `string` | yes | Id of the entry to cancel |
|
||||
|
||||
### `cancel_scheduled_ack` (broker → peer)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|-----------|----------|---------------------------------|
|
||||
| `scheduledId` | `string` | yes | Echoed id |
|
||||
| `ok` | `boolean` | yes | Whether cancellation succeeded |
|
||||
|
||||
---
|
||||
|
||||
## Crypto
|
||||
|
||||
- **Signing** — ed25519 (libsodium `crypto_sign`). One keypair per peer
|
||||
|
||||