23 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
e26a36e543 fix(broker): vector_stored type, set_state no-resp, subscribe ack
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- vector_store sends {type:"vector_stored",id}; wrapped in try/catch
- set_state no longer sends state_result (fire-and-forget)
- subscribe sends {type:"subscribed",stream} confirmation
- remove broken myPresence lookup in mesh_info
- add WSVectorStoredMessage + WSSubscribedMessage to types union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:08:06 +01:00
Alejandro Gutiérrez
60c74d9463 fix(broker): shareContext stable upsert key + createStream atomic upsert
- shareContext: adds optional memberId param; when provided, upserts on
  (meshId, memberId) instead of (meshId, presenceId) — prevents stale
  context rows accumulating on every reconnect. Falls back to presenceId
  for legacy/anonymous connections. Also refreshes presenceId on update
  so it stays current.
- schema: adds member_id column + unique index context_mesh_member_idx
  on mesh.context table; new migration 0013_context-stable-member-key.sql.
- index.ts call site updated to pass conn.memberId as the stable key.
- createStream: replaces SELECT-then-INSERT TOCTOU race with atomic
  INSERT ... ON CONFLICT DO NOTHING RETURNING, followed by SELECT on miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:58 +01:00
Alejandro Gutiérrez
6fba9bd4eb feat(cli): fix field mismatches + error propagation
- claim_task/complete_task: send taskId not id
- graph_result: read msg.records not msg.rows
- message_status: try all mesh clients, not only first
- broker: omit state_result for set_state (fixes get_state cross-contamination)
- error handler: unblock first pending resolver on unmatched broker errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:25 +01:00
Alejandro Gutiérrez
5bcc1fe323 chore(cli): bump to v0.6.4 — fix get_file sealedKey bug
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-07 12:57:20 +01:00
Alejandro Gutiérrez
e70f0ed1ff fix(broker/cli): e2e get_file owner sealedKey bug
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
broker: owner also fetches sealedKey from mesh.file_key (not skipped),
  only non-owners are blocked when key is missing
cli: explicit error when encrypted file has no sealedKey (no silent raw download)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:56:36 +01:00
Alejandro Gutiérrez
5f696f47ea feat(cli): v0.6.3 — e2e file crypto module + encrypted share_file
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- add crypto/file-crypto.ts: encryptFile, decryptFile, sealKeyForPeer, openSealedKey
- update share_file: when to= set, encrypts file + seals key per recipient
- update get_file: decrypts if encrypted + sealedKey present
- add grant_file_access tool: re-seals Kf for a new peer without re-upload
- add getSessionPubkey/getSessionSecretKey getters on BrokerClient
- add grantFileAccess WS method on BrokerClient
- bump version to 0.6.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:53:13 +01:00
Alejandro Gutiérrez
ccb9fb2a68 feat(broker/db): e2e file encryption schema + db functions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- add mesh.file_key table (fileId, peerPubkey, sealedKey, grantedByPubkey)
- add encrypted + ownerPubkey columns to mesh.file
- export insertFileKeys, getFileKey, grantFileKey from broker.ts
- update uploadFile/getFile/listFiles to include encrypted/ownerPubkey
- migration 0012_add-file-encryption applied to prod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:43:57 +01:00
Alejandro Gutiérrez
898c061089 feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:33:39 +01:00
Alejandro Gutiérrez
f7a6559429 feat(broker): add E2E file encryption to HTTP upload and WS handlers
- parse x-encrypted/x-owner-pubkey/x-file-keys headers in handleUploadPost
- pass encrypted and ownerPubkey to uploadFile, call insertFileKeys after
- get_file: fetch sealedKey for non-owners, block if missing, include in response
- list_files: include encrypted field per file
- add grant_file_access WS handler so owners can seal keys for peers
- update types.ts with new message interfaces and union members

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:32:46 +01:00
Alejandro Gutiérrez
579d0c3d3e chore: bump version to 0.6.0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:21:03 +01:00
Alejandro Gutiérrez
190f5a958e refactor(cli): migrate to citty — --help generated from flag definitions
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Replace manual switch + HELP string with citty defineCommand/runMain.
Flag definitions in index.ts are now the single source of truth for
--help output. Remove parseArgs() from launch.ts; accept citty-parsed
flags + rawArgs (-- passthrough to claude preserved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:19:16 +01:00
Alejandro Gutiérrez
03661e1b68 docs(cli): expand --help with all launch flags, groups hierarchy, env vars
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:16:04 +01:00
Alejandro Gutiérrez
d451fc296e feat: hierarchical group routing + role wiring
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
broker: expand member groups to ancestor paths at drain time (pull model)
- @flexicar message reaches peers in @flexicar/core, @flexicar/output, etc.
- Resolved at drainForMember — no DB changes, fully backward-compatible
- Any depth: flexicar/team/backend also matches @flexicar and @flexicar/team

cli: wire --role all the way through to session config + env
- Config.role field added
- launch.ts stores role in sessionConfig, passes CLAUDEMESH_ROLE env var
- mcp/server.ts includes role in identity string
- manager.ts auto-joins groups from config on WS connect (--groups flag now works)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:09:37 +01:00
Alejandro Gutiérrez
3da5d71275 fix(broker): fix share_file DB insert failures
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Normalise tags to Array before Drizzle insert (PgArray mapper calls
  .map() and throws if value is not a standard JS Array)
- Use uploadedByName instead of uploadedByMember FK — the X-Member-Id
  header carries the mesh slug, not a mesh.member primary key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:56:43 +01:00
Alejandro Gutiérrez
cdf335f609 fix(broker): fix MINIO_USE_SSL env coercion
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
z.coerce.boolean() treats any non-empty string as true, so MINIO_USE_SSL="false" → true.
Switch to explicit enum+transform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:38:06 +01:00
Alejandro Gutiérrez
0cd16ff358 fix: exclude sender only for broadcasts, not direct messages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The sender exclusion filter (excludeSenderSessionPubkey) was blocking
delivery of ALL messages from the sender, including direct messages
to other peers. Now only excludes on broadcast (target_spec = '*').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:34:09 +01:00
Alejandro Gutiérrez
3e9707276d fix: add diagnostic logging to maybePushQueuedMessages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:21:29 +01:00
Alejandro Gutiérrez
82cfee315c fix: v0.5.9 — mesh_info returns correct display name
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:10:30 +01:00
Alejandro Gutiérrez
ab08be04a5 feat(cli): v0.5.8 — welcome notification on connect
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:07:08 +01:00
Alejandro Gutiérrez
ee585a8370 fix(cli): v0.5.7 — event loop keepalive for stdout flush
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Node.js stdout to a pipe is buffered. Without periodic event loop
activity, WS callback → server.notification() → stdout.write() may
not flush until the next I/O event. A 1s setInterval (NOT unref'd)
keeps the event loop ticking so notifications flush immediately.

This is why claude-intercom worked: its 1s HTTP poll kept the event
loop active as a side effect. Claudemesh's passive WS listener let
the event loop settle, causing stdout to buffer indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:48:41 +01:00
Alejandro Gutiérrez
1f078bf0c8 fix(web): --no-turbopack for prod build (payload CSS)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:24 +01:00
Alejandro Gutiérrez
2372032a68 fix(cli): v0.5.6 — fix ping_mesh self-send + add diagnostics
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:03 +01:00
Alejandro Gutiérrez
a70c5fd124 feat(cli): v0.5.5 — ping_mesh diagnostic tool
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled
Sends test messages to self through the full pipeline per priority
and measures round-trip timing. Reports send→ack and send→receive
latency. Detects broker priority gating (status=working holds next/low).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:27:00 +01:00
18 changed files with 10027 additions and 270 deletions

View File

@@ -34,6 +34,7 @@ import {
mesh,
meshFile,
meshFileAccess,
meshFileKey,
meshContext,
meshMember as memberTable,
meshMemory,
@@ -717,6 +718,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 +735,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 +760,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 +775,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 +791,8 @@ export async function getFile(
return {
...row,
tags: (row.tags ?? []) as string[],
encrypted: row.encrypted,
ownerPubkey: row.ownerPubkey,
};
}
@@ -801,6 +812,7 @@ export async function listFiles(
uploadedBy: string;
uploadedAt: Date;
persistent: boolean;
encrypted: boolean;
}>
> {
const conditions = [
@@ -822,6 +834,7 @@ export async function listFiles(
uploadedByName: meshFile.uploadedByName,
uploadedAt: meshFile.uploadedAt,
persistent: meshFile.persistent,
encrypted: meshFile.encrypted,
})
.from(meshFile)
.where(and(...conditions))
@@ -835,6 +848,7 @@ export async function listFiles(
uploadedBy: r.uploadedByName ?? "unknown",
uploadedAt: r.uploadedAt,
persistent: r.persistent,
encrypted: r.encrypted,
}));
}
@@ -892,11 +906,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 +971,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 +1007,7 @@ export async function shareContext(
.insert(meshContext)
.values({
meshId,
memberId: memberId ?? null,
presenceId,
peerName: peerName ?? null,
summary,
@@ -1188,16 +1257,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 +1377,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 +1429,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
)

View File

@@ -23,7 +23,7 @@ const envSchema = z.object({
MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.coerce.boolean().default(false),
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"),

View File

@@ -31,10 +31,13 @@ import {
forgetMemory,
getContext,
getFile,
getFileKey,
getFileStatus,
getState,
grantFileKey,
handleHookSetStatus,
heartbeat,
insertFileKeys,
joinGroup,
joinMesh,
leaveGroup,
@@ -123,7 +126,10 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey?: string,
): Promise<void> {
const conn = connections.get(presenceId);
if (!conn) return;
if (!conn) {
log.debug("maybePush: no connection for presence", { presence_id: presenceId });
return;
}
const status = await refreshStatusFromJsonl(
presenceId,
conn.cwd,
@@ -138,6 +144,13 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey,
conn.groups.map((g) => g.name),
);
log.info("maybePush", {
presence_id: presenceId,
status,
session_pubkey: conn.sessionPubkey?.slice(0, 12),
exclude: excludeSenderSessionPubkey?.slice(0, 12),
drained: messages.length,
});
for (const m of messages) {
const push: WSPushMessage = {
type: "push",
@@ -368,6 +381,9 @@ function handleUploadPost(
const tagsRaw = req.headers["x-tags"] as string | undefined;
const persistentRaw = req.headers["x-persistent"] as string | undefined;
const targetSpec = req.headers["x-target-spec"] as string | undefined;
const encryptedRaw = req.headers["x-encrypted"] as string | undefined;
const ownerPubkey = req.headers["x-owner-pubkey"] as string | undefined;
const fileKeysRaw = req.headers["x-file-keys"] as string | undefined;
if (!meshId || !memberId || !fileName) {
writeJson(res, 400, {
@@ -435,19 +451,44 @@ function handleUploadPost(
: undefined,
);
// Insert DB row
// Insert DB row — normalise tags to a real JS Array (Drizzle PgArray
// mapper calls .map() on the value; non-Array iterables break it).
// Skip uploadedByMember FK — memberId from the client header is the
// mesh slug, not a mesh.member primary key.
const encrypted = encryptedRaw === "true";
let fileKeys: Array<{ peerPubkey: string; sealedKey: string }> = [];
if (encrypted && fileKeysRaw) {
try {
fileKeys = JSON.parse(fileKeysRaw);
} catch { /* ignore */ }
}
const dbFileId = await uploadFile({
meshId,
name: fileName,
sizeBytes: body.length,
mimeType: (req.headers["content-type"] as string) || undefined,
minioKey,
tags,
tags: Array.isArray(tags) ? tags : [],
persistent,
uploadedByMember: memberId,
uploadedByName: memberId || undefined,
uploadedByMember: undefined,
targetSpec: targetSpec || undefined,
encrypted: encrypted || false,
ownerPubkey: ownerPubkey || undefined,
});
if (encrypted && fileKeys.length > 0) {
await insertFileKeys(
dbFileId,
fileKeys.map((k) => ({
peerPubkey: k.peerPubkey,
sealedKey: k.sealedKey,
grantedByPubkey: ownerPubkey,
})),
);
}
writeJson(res, 200, { ok: true, fileId: dbFileId });
log.info("upload", {
route: "POST /upload",
@@ -803,14 +844,8 @@ function handleConnection(ws: WebSocket): void {
updatedBy: stateRow.updatedBy,
});
}
// Send confirmation back to sender as state_result.
sendToPeer(presenceId, {
type: "state_result",
key: stateRow.key,
value: stateRow.value,
updatedBy: stateRow.updatedBy,
updatedAt: stateRow.updatedAt.toISOString(),
});
// Fire-and-forget: no state_result sent back to sender.
// The client (server.ts) returns success immediately without waiting.
log.info("ws set_state", {
presence_id: presenceId,
key: ss.key,
@@ -936,6 +971,19 @@ function handleConnection(ws: WebSocket): void {
break;
}
}
// E2E: for encrypted files, fetch the sealed key for this peer.
// Owners are not blocked if their key is missing (edge case), but
// they still get it returned so the CLI can decrypt normally.
let sealedKey: string | null = null;
if (file.encrypted) {
const peerPubkey = conn.sessionPubkey ?? conn.memberPubkey;
const isOwner = !!(file.ownerPubkey && peerPubkey === file.ownerPubkey);
sealedKey = peerPubkey ? await getFileKey(gf.fileId, peerPubkey) : null;
if (!sealedKey && !isOwner) {
sendError(conn.ws, "forbidden", "no decryption key for this file");
break;
}
}
// Generate presigned URL (60s expiry)
const bucket = meshBucketName(conn.meshId);
const presignedUrl = await minioClient.presignedGetObject(
@@ -957,6 +1005,8 @@ function handleConnection(ws: WebSocket): void {
fileId: gf.fileId,
url: presignedUrl,
name: file.name,
encrypted: file.encrypted,
sealedKey: sealedKey ?? undefined,
});
log.info("ws get_file", {
presence_id: presenceId,
@@ -977,6 +1027,7 @@ function handleConnection(ws: WebSocket): void {
uploadedBy: f.uploadedBy,
uploadedAt: f.uploadedAt.toISOString(),
persistent: f.persistent,
encrypted: f.encrypted,
})),
});
log.info("ws list_files", {
@@ -1003,6 +1054,23 @@ function handleConnection(ws: WebSocket): void {
});
break;
}
case "grant_file_access": {
const gfa = msg as { type: "grant_file_access"; fileId: string; peerPubkey: string; sealedKey: string };
const file = await getFile(conn.meshId, gfa.fileId);
if (!file) {
sendError(conn.ws, "not_found", "file not found");
break;
}
const requestorPubkey = conn.sessionPubkey ?? conn.memberPubkey;
if (file.ownerPubkey && file.ownerPubkey !== requestorPubkey) {
sendError(conn.ws, "forbidden", "only the file owner can grant access");
break;
}
await grantFileKey(gfa.fileId, gfa.peerPubkey, gfa.sealedKey, requestorPubkey ?? undefined);
sendToPeer(presenceId, { type: "grant_file_access_ok", fileId: gfa.fileId, peerPubkey: gfa.peerPubkey });
log.info("ws grant_file_access", { presence_id: presenceId, file_id: gfa.fileId, peer: gfa.peerPubkey });
break;
}
case "delete_file": {
const df = msg as Extract<WSClientMessage, { type: "delete_file" }>;
await deleteFile(conn.meshId, df.fileId);
@@ -1097,6 +1165,7 @@ function handleConnection(ws: WebSocket): void {
sc.filesRead,
sc.keyFindings,
sc.tags,
conn.memberId,
);
sendToPeer(presenceId, {
type: "context_shared",
@@ -1307,6 +1376,7 @@ function handleConnection(ws: WebSocket): void {
if (!streamSubscriptions.has(key))
streamSubscriptions.set(key, new Set());
streamSubscriptions.get(key)!.add(presenceId);
sendToPeer(presenceId, { type: "subscribed", stream: sub.stream });
log.info("ws subscribe", {
presence_id: presenceId,
stream: sub.stream,
@@ -1379,40 +1449,42 @@ function handleConnection(ws: WebSocket): void {
case "vector_store": {
const vs = msg as Extract<WSClientMessage, { type: "vector_store" }>;
const collName = meshCollectionName(conn.meshId, vs.collection);
await ensureCollection(collName);
const { generateId } = await import("@turbostarter/shared/utils");
const pointId = generateId();
// Store text + metadata as payload. Use a zero vector as placeholder
// — real embeddings should be computed client-side and sent directly
// to Qdrant in a future version.
const zeroVector = new Array(1536).fill(0) as number[];
await qdrant.upsert(collName, {
wait: true,
points: [
{
id: pointId,
vector: zeroVector,
payload: {
text: vs.text,
mesh_id: conn.meshId,
stored_by: conn.memberPubkey,
stored_at: new Date().toISOString(),
...(vs.metadata ?? {}),
try {
await ensureCollection(collName);
const { generateId } = await import("@turbostarter/shared/utils");
const pointId = generateId();
// Store text + metadata as payload. Use a zero vector as placeholder
// — real embeddings should be computed client-side and sent directly
// to Qdrant in a future version.
const zeroVector = new Array(1536).fill(0) as number[];
await qdrant.upsert(collName, {
wait: true,
points: [
{
id: pointId,
vector: zeroVector,
payload: {
text: vs.text,
mesh_id: conn.meshId,
stored_by: conn.memberPubkey,
stored_at: new Date().toISOString(),
...(vs.metadata ?? {}),
},
},
},
],
});
sendToPeer(presenceId, {
type: "ack" as const,
id: pointId,
messageId: pointId,
queued: false,
});
log.info("ws vector_store", {
presence_id: presenceId,
collection: vs.collection,
point_id: pointId,
});
],
});
sendToPeer(presenceId, {
type: "vector_stored",
id: pointId,
});
log.info("ws vector_store", {
presence_id: presenceId,
collection: vs.collection,
point_id: pointId,
});
} catch (e) {
sendError(conn.ws, "vector_error", e instanceof Error ? e.message : String(e));
}
break;
}
case "vector_search": {
@@ -1656,8 +1728,10 @@ function handleConnection(ws: WebSocket): void {
]);
const allGroups = new Set<string>();
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`);
const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
const peerConn = connections.get(presenceId);
// Find own display name: match sessionPubkey from the peer list
const selfPubkey = peerConn?.sessionPubkey ?? peerConn?.memberPubkey;
const selfPeer = peers.find(p => p.pubkey === selfPubkey);
sendToPeer(presenceId, {
type: "mesh_info_result",
mesh: conn.meshId,
@@ -1670,7 +1744,7 @@ function handleConnection(ws: WebSocket): void {
streams: streams.map(s => s.name),
tables: tables.map((t: any) => t.name),
collections: [],
yourName: peerConn?.groups?.[0]?.name ?? "unknown",
yourName: selfPeer?.displayName ?? "unknown",
yourGroups: peerConn?.groups ?? [],
});
log.info("ws mesh_info", { presence_id: presenceId });

View File

@@ -295,6 +295,12 @@ export interface WSMeshSchemaMessage {
// --- Vector/Graph response messages ---
/** Broker → client: confirmation that a vector point was stored. */
export interface WSVectorStoredMessage {
type: "vector_stored";
id: string;
}
/** Broker → client: vector search results. */
export interface WSVectorResultsMessage {
type: "vector_results";
@@ -404,12 +410,22 @@ 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;
}
/** Broker → client: list of files in the mesh. */
@@ -423,9 +439,17 @@ export interface WSFileListMessage {
uploadedBy: string;
uploadedAt: string;
persistent: boolean;
encrypted: boolean;
}>;
}
/** Broker → client: acknowledgement for grant_file_access. */
export interface WSGrantFileAccessOkMessage {
type: "grant_file_access_ok";
fileId: string;
peerPubkey: string;
}
/** Broker → client: access log for a file. */
export interface WSFileStatusResultMessage {
type: "file_status_result";
@@ -588,6 +612,12 @@ export interface WSStreamDataMessage {
publishedBy: string;
}
/** Broker → client: confirmation that a stream subscription was registered. */
export interface WSSubscribedMessage {
type: "subscribed";
stream: string;
}
/** Broker → client: response to list_streams. */
export interface WSStreamListMessage {
type: "stream_list";
@@ -627,6 +657,7 @@ export type WSClientMessage =
| WSListFilesMessage
| WSFileStatusMessage
| WSDeleteFileMessage
| WSGrantFileAccessMessage
| WSShareContextMessage
| WSGetContextMessage
| WSListContextsMessage
@@ -664,11 +695,13 @@ export type WSServerMessage =
| WSFileUrlMessage
| WSFileListMessage
| WSFileStatusResultMessage
| WSGrantFileAccessOkMessage
| WSContextSharedMessage
| WSContextResultsMessage
| WSContextListMessage
| WSTaskCreatedMessage
| WSTaskListMessage
| WSVectorStoredMessage
| WSVectorResultsMessage
| WSCollectionListMessage
| WSGraphResultMessage
@@ -676,6 +709,7 @@ export type WSServerMessage =
| WSMeshSchemaResultMessage
| WSStreamCreatedMessage
| WSStreamDataMessage
| WSSubscribedMessage
| WSStreamListMessage
| WSMeshInfoResultMessage
| WSErrorMessage;

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "0.5.4",
"version": "0.6.4",
"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"

View File

@@ -1,9 +1,12 @@
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Flags are defined in index.ts (citty command) — that is the source of
* truth. This file receives already-parsed flags and rawArgs.
*
* Flow:
* 1. Parse --name, --join, --mesh, --quiet flags
* 2. If --join: run join flow first (accepts token or URL)
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
@@ -18,73 +21,17 @@ import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
// --- Arg parsing ---
interface LaunchArgs {
name: string | null;
role: string | null;
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
joinLink: string | null;
meshSlug: string | null;
messageMode: "push" | "inbox" | "off" | null;
quiet: boolean;
skipPermConfirm: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
role: null,
groups: null,
joinLink: null,
meshSlug: null,
messageMode: null,
quiet: false,
skipPermConfirm: false,
claudeArgs: [],
};
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--name" && i + 1 < argv.length) {
result.name = argv[++i]!;
} else if (arg.startsWith("--name=")) {
result.name = arg.slice("--name=".length);
} else if (arg === "--role" && i + 1 < argv.length) {
result.role = argv[++i]!;
} else if (arg.startsWith("--role=")) {
result.role = arg.slice("--role=".length);
} else if (arg === "--groups" && i + 1 < argv.length) {
result.groups = argv[++i]!;
} else if (arg.startsWith("--groups=")) {
result.groups = arg.slice("--groups=".length);
} else if (arg === "--join" && i + 1 < argv.length) {
result.joinLink = argv[++i]!;
} else if (arg.startsWith("--join=")) {
result.joinLink = arg.slice("--join=".length);
} else if (arg === "--mesh" && i + 1 < argv.length) {
result.meshSlug = argv[++i]!;
} else if (arg.startsWith("--mesh=")) {
result.meshSlug = arg.slice("--mesh=".length);
} else if (arg === "--inbox") {
result.messageMode = "inbox";
} else if (arg === "--no-messages") {
result.messageMode = "off";
} else if (arg === "--quiet") {
result.quiet = true;
} else if (arg === "-y" || arg === "--yes") {
result.skipPermConfirm = true;
} else if (arg === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
name?: string;
role?: string;
groups?: string;
join?: string;
mesh?: string;
"message-mode"?: string;
"system-prompt"?: string;
yes?: boolean;
quiet?: boolean;
}
// --- Interactive mesh picker ---
@@ -206,8 +153,26 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
// --- Main ---
export async function runLaunch(extraArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs);
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
// Extract args that follow "--" — passed straight through to claude.
const dashIdx = rawArgs.indexOf("--");
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
// Normalise flags into the internal shape used below.
const args = {
name: flags.name ?? null,
role: flags.role ?? null,
groups: flags.groups ?? null,
joinLink: flags.join ?? null,
meshSlug: flags.mesh ?? null,
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
? flags["message-mode"] as "push" | "inbox" | "off"
: null),
systemPrompt: flags["system-prompt"] ?? null,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first.
if (args.joinLink) {
@@ -318,6 +283,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
@@ -351,6 +317,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
"--dangerously-load-development-channels",
"server:claudemesh",
"--dangerously-skip-permissions",
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered,
];
@@ -362,6 +329,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(role ? { CLAUDEMESH_ROLE: role } : {}),
},
});

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

View File

@@ -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";
@@ -21,96 +23,152 @@ import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome";
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: "Launch Claude Code connected to a mesh with real-time peer messaging",
},
args: {
name: {
type: "string",
description: "Display name for this session",
},
role: {
type: "string",
description: "Role tag (dev, lead, analyst — free-form)",
},
groups: {
type: "string",
description: 'Groups to join: "group:role,group2" — colon sets role. Hierarchy via slash: "eng/frontend:lead"',
},
mesh: {
type: "string",
description: "Select mesh by 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 — controls how peer messages are delivered",
},
"system-prompt": {
type: "string",
description: "Set Claude's system prompt for this session",
},
yes: {
type: "boolean",
alias: "y",
description: "Skip permission confirmation",
default: false,
},
quiet: {
type: "boolean",
description: "Skip banner and all 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 + status hooks with Claude Code",
},
args: {
"no-hooks": {
type: "boolean",
description: "Register MCP server only, skip hooks",
default: false,
},
},
run({ rawArgs }) {
runInstall(rawArgs);
},
});
const join = defineCommand({
meta: {
name: "join",
description: "Join a mesh via invite URL",
},
args: {
url: {
type: "positional",
description: "Invite URL (https://claudemesh.com/join/...)",
required: true,
},
},
run({ args }) {
return runJoin([args.url]);
},
});
const leave = defineCommand({
meta: {
name: "leave",
description: "Leave a joined mesh",
},
args: {
slug: {
type: "positional",
description: "Mesh slug to leave",
required: true,
},
},
run({ args }) {
runLeave([args.slug]);
},
});
const main = defineCommand({
meta: {
name: "claudemesh",
version: VERSION,
description: "Peer mesh for Claude Code sessions",
},
subCommands: {
launch,
install,
uninstall: defineCommand({
meta: { name: "uninstall", description: "Remove MCP server and hooks" },
run() { runUninstall(); },
}),
join,
list: defineCommand({
meta: { name: "list", description: "Show joined meshes and identities" },
run() { runList(); },
}),
leave,
status: defineCommand({
meta: { name: "status", description: "Check broker reachability for each joined mesh" },
async run() { await runStatus(); },
}),
doctor: defineCommand({
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH" },
async run() { await runDoctor(); },
}),
mcp: defineCommand({
meta: { name: "mcp", description: "Start MCP server (stdio — invoked by Claude Code, not users)" },
async run() { await startMcpServer(); },
}),
"seed-test-mesh": defineCommand({
meta: { name: "seed-test-mesh", description: "Dev only: inject a mesh into config (skips invite flow)" },
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
}),
hook: defineCommand({
meta: { name: "hook", description: "Internal hook handler (invoked by Claude Code hooks)" },
async run({ rawArgs }) { await runHook(rawArgs); },
}),
},
run() {
runWelcome();
},
});
runMain(main);

View File

@@ -130,6 +130,7 @@ export async function startMcpServer(): Promise<void> {
const config = loadConfig();
const myName = config.displayName ?? "unnamed";
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push";
@@ -141,7 +142,7 @@ export async function startMcpServer(): Promise<void> {
tools: {},
},
instructions: `## Identity
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
@@ -326,9 +327,15 @@ Your message mode is "${messageMode}".
case "message_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("message_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("message_status: not connected", true);
const result = await client.messageStatus(id);
const clients = allClients();
if (!clients.length) return text("message_status: not connected", true);
// Try each connected mesh client — we don't know which mesh the
// messageId belongs to, so query all and return the first hit.
let result = null;
for (const c of clients) {
result = await c.messageStatus(id);
if (result) break;
}
if (!result) return text(`Message ${id} not found or timed out.`);
const recipientLines = result.recipients.map(
(r: { name: string; pubkey: string; status: string }) =>
@@ -439,17 +446,83 @@ Your message mode is "${messageMode}".
// --- Files ---
case "share_file": {
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] };
const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string };
if (!filePath) return text("share_file: `path` required", true);
const { existsSync } = await import("node:fs");
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0];
if (!client) return text("share_file: not connected", true);
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true,
});
if (!fileId) return text("share_file: upload failed", true);
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
// If 'to' specified, do E2E encryption
if (fileTo) {
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join, basename } = await import("node:path");
// Resolve target peer pubkey
const peers = await client.listPeers();
const targetPeer = peers.find(p => p.pubkey === fileTo || p.displayName === fileTo);
if (!targetPeer) {
return text(`share_file: peer not found: ${fileTo}`, true);
}
// Read and encrypt file
const plaintext = readFileSync(filePath);
const { ciphertext, nonce, key } = await encryptFile(new Uint8Array(plaintext));
// Seal Kf for target peer
const sealedForTarget = await sealKeyForPeer(key, targetPeer.pubkey);
// Seal Kf for ourselves (owner)
const myPubkey = client.getSessionPubkey();
const sealedForSelf = myPubkey ? await sealKeyForPeer(key, myPubkey) : null;
const fileKeys = [
{ peerPubkey: targetPeer.pubkey, sealedKey: sealedForTarget },
...(sealedForSelf && myPubkey ? [{ peerPubkey: myPubkey, sealedKey: sealedForSelf }] : []),
];
// Build combined buffer: nonce (24 bytes) + ciphertext
const { ensureSodium } = await import("../crypto/keypair");
const sodium = await ensureSodium();
const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL);
const combined = new Uint8Array(nonceBytes.length + ciphertext.length);
combined.set(nonceBytes, 0);
combined.set(ciphertext, nonceBytes.length);
const baseName = fileName ?? basename(filePath);
const tmpDir = mkdtempSync(join(tmpdir(), "cm-"));
const tmpPath = join(tmpDir, baseName);
writeFileSync(tmpPath, combined);
try {
const fileId = await client.uploadFile(tmpPath, client.meshId, client.meshSlug, {
name: baseName,
tags,
persistent: true,
encrypted: true,
ownerPubkey: myPubkey ?? undefined,
fileKeys,
});
return text(`Shared (E2E encrypted): ${baseName}${targetPeer.displayName} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
} finally {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
try { rmdirSync(tmpDir); } catch { /* ignore */ }
}
}
// Plain (unencrypted) upload — existing code
try {
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true,
});
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
} catch (e) {
return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true);
}
}
case "get_file": {
@@ -459,6 +532,43 @@ Your message mode is "${messageMode}".
if (!client) return text("get_file: not connected", true);
const result = await client.getFile(id);
if (!result) return text(`get_file: file ${id} not found`, true);
if (result.encrypted) {
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) {
return text("get_file: no session keypair — cannot decrypt", true);
}
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("get_file: failed to open sealed key", true);
// Download file bytes from presigned URL
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
const buf = new Uint8Array(await resp.arrayBuffer());
// Wire format: first 24 bytes = nonce, rest = ciphertext
const sodium = await ensureSodium();
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
const ciphertext = buf.slice(NONCE_BYTES);
const plaintext = await decryptFile(ciphertext, nonce, kf);
if (!plaintext) return text("get_file: decryption failed", true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
mkdirSync(dirname(save_to), { recursive: true });
writeFileSync(save_to, plaintext);
return text(`Downloaded and decrypted: ${result.name}${save_to}`);
}
// Unencrypted — existing download logic
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
const { writeFileSync, mkdirSync } = await import("node:fs");
@@ -707,6 +817,86 @@ Your message mode is "${messageMode}".
return text(lines.join("\n"));
}
case "ping_mesh": {
const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] };
const toTest = (pingPriorities ?? ["now", "next"]) as Priority[];
const client = allClients()[0];
if (!client) return text("ping_mesh: not connected", true);
const results: string[] = [];
// Diagnostics: connection state
results.push(`WS status: ${client.status}`);
results.push(`Mesh: ${client.meshSlug}`);
// Check own peer status (explains priority gating)
const peers = await client.listPeers();
const selfPeer = peers.find(p => p.displayName === myName);
results.push(`Your status: ${selfPeer?.status ?? "not found in peer list"}`);
results.push(`Peers online: ${peers.length}`);
results.push(`Push buffer: ${client.pushHistory.length} buffered`);
// Test send→ack latency per priority (doesn't need round-trip)
for (const prio of toTest) {
const sendTime = Date.now();
// Send to a peer if one exists, otherwise broadcast
const target = peers.find(p => p.displayName !== myName);
const sendResult = await client.send(
target?.pubkey ?? "*",
`__ping__ ${prio} from ${myName} at ${new Date().toISOString()}`,
prio,
);
const ackTime = Date.now();
if (!sendResult.ok) {
results.push(`[${prio}] SEND FAILED: ${sendResult.error}`);
} else {
results.push(`[${prio}] send→ack: ${ackTime - sendTime}ms (msgId: ${sendResult.messageId?.slice(0, 12)})`);
if (prio !== "now" && selfPeer?.status === "working") {
results.push(` ⚠ peer status is "working" — broker holds "${prio}" until idle`);
}
}
}
// Check if notification pipeline works
results.push("");
results.push("Pipeline check:");
results.push(` onPush handlers: active`);
results.push(` messageMode: ${messageMode}`);
results.push(` server.notification: ${messageMode === "off" ? "disabled (mode=off)" : "enabled"}`);
return text(results.join("\n"));
}
case "grant_file_access": {
const { fileId, to: grantTo } = (args ?? {}) as { fileId?: string; to?: string };
if (!fileId || !grantTo) return text("grant_file_access: `fileId` and `to` required", true);
const client = allClients()[0];
if (!client) return text("grant_file_access: not connected", true);
const peers = await client.listPeers();
const targetPeer = peers.find(p => p.pubkey === grantTo || p.displayName === grantTo);
if (!targetPeer) return text(`grant_file_access: peer not found: ${grantTo}`, true);
const result = await client.getFile(fileId);
if (!result) return text("grant_file_access: file not found", true);
if (!result.encrypted) return text("grant_file_access: file is not encrypted", true);
if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true);
const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true);
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("grant_file_access: cannot decrypt your own key", true);
const sealedForPeer = await sealKeyForPeer(kf, targetPeer.pubkey);
const ok = await client.grantFileAccess(fileId, targetPeer.pubkey, sealedForPeer);
if (!ok) return text("grant_file_access: broker did not confirm", true);
return text(`Access granted: ${targetPeer.displayName} can now download file ${fileId}`);
}
default:
return text(`Unknown tool: ${name}`, true);
}
@@ -806,7 +996,42 @@ Your message mode is "${messageMode}".
});
}
// Welcome notification: give Claude immediate context on connect.
// Triggers Claude to call mesh_info/list_peers without user input.
setTimeout(async () => {
const client = allClients()[0];
if (!client || client.status !== "open") return;
try {
const peers = await client.listPeers();
const peerNames = peers
.filter(p => p.displayName !== myName)
.map(p => p.displayName)
.join(", ") || "none";
await server.notification({
method: "notifications/claude/channel",
params: {
content: `[system] Connected as ${myName} to mesh ${client.meshSlug}. ${peers.length} peer(s) online: ${peerNames}. Call mesh_info for full details or set_summary to announce yourself.`,
meta: { kind: "welcome", mesh_slug: client.meshSlug },
},
});
} catch { /* best effort */ }
}, 3_000); // 3s delay: let WS connect + hello_ack complete first
// Event loop keepalive: Node.js stdout to a pipe is buffered. Without
// periodic event loop activity, stdout.write() from WS callbacks may not
// flush until the next I/O event. This 1s interval keeps the event loop
// ticking so channel notifications flush promptly — same pattern that made
// claude-intercom's push delivery reliable (its 1s HTTP poll had this
// effect as a side effect). The interval does nothing except prevent the
// event loop from settling.
const keepalive = setInterval(() => {
// Intentionally empty — the interval itself keeps the event loop active.
// Do NOT call .unref() — that would defeat the purpose.
}, 1_000);
void keepalive; // suppress unused warning
const shutdown = (): void => {
clearInterval(keepalive);
stopAll();
process.exit(0);
};

View File

@@ -203,7 +203,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 +217,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 +273,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 ---
{
@@ -555,4 +571,21 @@ export const TOOLS: Tool[] = [
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
inputSchema: { type: "object", properties: {} },
},
// --- Diagnostics ---
{
name: "ping_mesh",
description:
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
inputSchema: {
type: "object",
properties: {
priorities: {
type: "array",
items: { type: "string", enum: ["now", "next", "low"] },
description: "Priorities to test (default: [\"now\", \"next\"])",
},
},
},
},
];

View File

@@ -37,6 +37,7 @@ export interface Config {
version: 1;
meshes: JoinedMesh[];
displayName?: string; // per-session override, written by `claudemesh launch --name`
role?: string; // per-session role tag (display + hello)
groups?: GroupEntry[];
messageMode?: "push" | "inbox" | "off";
}
@@ -54,7 +55,7 @@ export function loadConfig(): Config {
if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] };
}
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode };
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode };
} catch (e) {
throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -83,6 +83,7 @@ export class BrokerClient {
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private grantFileAccessResolvers: Array<(ok: boolean) => void> = [];
private closed = false;
private reconnectAttempt = 0;
private helloTimer: NodeJS.Timeout | null = null;
@@ -110,6 +111,11 @@ export class BrokerClient {
return this.pushBuffer;
}
/** Session public key hex (null before first connection). */
getSessionPubkey(): string | null { return this.sessionPubkey; }
/** Session secret key hex (null before first connection). */
getSessionSecretKey(): string | null { return this.sessionSecretKey; }
/** Open WS, send hello, resolve when hello_ack received. */
async connect(): Promise<void> {
if (this.closed) throw new Error("client is closed");
@@ -412,7 +418,7 @@ export class BrokerClient {
/** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = [];
private fileUrlResolvers: Array<(result: { url: string; name: string; encrypted?: boolean; sealedKey?: string } | null) => void> = [];
private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = [];
private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
private vectorStoredResolvers: Array<(id: string | null) => void> = [];
@@ -444,7 +450,7 @@ export class BrokerClient {
// --- Files ---
/** Get a download URL for a shared file. */
async getFile(fileId: string): Promise<{ url: string; name: string } | null> {
async getFile(fileId: string): Promise<{ url: string; name: string; encrypted?: boolean; sealedKey?: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.fileUrlResolvers.push(resolve);
@@ -497,10 +503,11 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
}
/** Upload a file to the broker via HTTP POST. Returns file ID or null. */
/** Upload a file to the broker via HTTP POST. Returns file ID. */
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
}): Promise<string | null> {
encrypted?: boolean; ownerPubkey?: string; fileKeys?: Array<{ peerPubkey: string; sealedKey: string }>;
}): Promise<string> {
const { readFileSync } = await import("node:fs");
const { basename } = await import("node:path");
const data = readFileSync(filePath);
@@ -522,12 +529,32 @@ export class BrokerClient {
"X-Tags": JSON.stringify(opts.tags ?? []),
"X-Persistent": String(opts.persistent ?? true),
"X-Target-Spec": opts.targetSpec ?? "",
...(opts.encrypted ? { "X-Encrypted": "true" } : {}),
...(opts.ownerPubkey ? { "X-Owner-Pubkey": opts.ownerPubkey } : {}),
...(opts.fileKeys?.length ? { "X-File-Keys": JSON.stringify(opts.fileKeys) } : {}),
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = await res.json() as { ok?: boolean; fileId?: string };
return body.fileId ?? null;
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
if (!res.ok || !body.fileId) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body.fileId;
}
/** Grant a peer access to an encrypted file (owner only). */
async grantFileAccess(fileId: string, peerPubkey: string, sealedKey: string): Promise<boolean> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
return new Promise((resolve) => {
const resolvers = this.grantFileAccessResolvers;
resolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "grant_file_access", fileId, peerPubkey, sealedKey }));
setTimeout(() => {
const idx = resolvers.indexOf(resolve);
if (idx !== -1) { resolvers.splice(idx, 1); resolve(false); }
}, 5_000);
});
}
// --- Vectors ---
@@ -657,13 +684,13 @@ export class BrokerClient {
/** Claim an unclaimed task. */
async claimTask(id: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "claim_task", id }));
this.ws.send(JSON.stringify({ type: "claim_task", taskId: id }));
}
/** Mark a task done with optional result. */
async completeTask(id: string, result?: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "complete_task", id, result }));
this.ws.send(JSON.stringify({ type: "complete_task", taskId: id, result }));
}
/** List tasks filtered by status/assignee. */
@@ -890,6 +917,11 @@ export class BrokerClient {
return;
}
if (msg.type === "state_result") {
// DEPENDENCY: The broker must NOT send state_result for set_state
// operations (only for get_state). If the broker sends state_result for
// both, it would be consumed here by the next pending get_state resolver,
// returning the wrong value (cross-contamination). The broker's set_state
// handler was fixed to omit state_result; only get_state sends it.
const resolver = this.stateResolvers.shift();
if (resolver) {
if (msg.key) {
@@ -942,7 +974,12 @@ export class BrokerClient {
const resolver = this.fileUrlResolvers.shift();
if (resolver) {
if (msg.url) {
resolver({ url: String(msg.url), name: String(msg.name ?? "") });
resolver({
url: String(msg.url),
name: String(msg.name ?? ""),
encrypted: msg.encrypted ? true : undefined,
sealedKey: msg.sealedKey ? String(msg.sealedKey) : undefined,
});
} else {
resolver(null);
}
@@ -961,6 +998,11 @@ export class BrokerClient {
if (resolver) resolver(accesses);
return;
}
if (msg.type === "grant_file_access_ok") {
const resolver = this.grantFileAccessResolvers.shift();
if (resolver) resolver(true);
return;
}
if (msg.type === "vector_stored") {
const resolver = this.vectorStoredResolvers.shift();
if (resolver) resolver(msg.id ? String(msg.id) : null);
@@ -979,7 +1021,8 @@ export class BrokerClient {
return;
}
if (msg.type === "graph_result") {
const rows = (msg.rows as Array<Record<string, unknown>>) ?? [];
// Broker sends { type: "graph_result", records: [...] }
const rows = (msg.records as Array<Record<string, unknown>>) ?? [];
const resolver = this.graphResultResolvers.shift();
if (resolver) resolver(rows);
return;
@@ -1058,6 +1101,7 @@ export class BrokerClient {
if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null;
let handledByPendingSend = false;
if (id) {
const pending = this.pendingSends.get(id);
if (pending) {
@@ -1066,6 +1110,43 @@ export class BrokerClient {
error: `${msg.code}: ${msg.message}`,
});
this.pendingSends.delete(id);
handledByPendingSend = true;
}
}
if (!handledByPendingSend) {
// Best-effort: unblock the first waiting resolver so callers don't
// hang for 5s. We don't know which tool triggered the error, so we
// pop the first non-empty resolver queue in priority order.
if (this.stateResolvers.length > 0) {
this.stateResolvers.shift()!(null);
} else if (this.stateListResolvers.length > 0) {
this.stateListResolvers.shift()!([]);
} else if (this.memoryStoreResolvers.length > 0) {
this.memoryStoreResolvers.shift()!(null);
} else if (this.memoryRecallResolvers.length > 0) {
this.memoryRecallResolvers.shift()!([]);
} else if (this.fileUrlResolvers.length > 0) {
this.fileUrlResolvers.shift()!(null);
} else if (this.fileListResolvers.length > 0) {
this.fileListResolvers.shift()!([]);
} else if (this.fileStatusResolvers.length > 0) {
this.fileStatusResolvers.shift()!([]);
} else if (this.graphResultResolvers.length > 0) {
this.graphResultResolvers.shift()!([]);
} else if (this.vectorStoredResolvers.length > 0) {
this.vectorStoredResolvers.shift()!(null);
} else if (this.vectorResultsResolvers.length > 0) {
this.vectorResultsResolvers.shift()!([]);
} else if (this.taskListResolvers.length > 0) {
this.taskListResolvers.shift()!([]);
} else if (this.meshQueryResolvers.length > 0) {
this.meshQueryResolvers.shift()!(null);
} else if (this.contextResultsResolvers.length > 0) {
this.contextResultsResolvers.shift()!([]);
} else if (this.contextListResolvers.length > 0) {
this.contextListResolvers.shift()!([]);
} else if (this.streamListResolvers.length > 0) {
this.streamListResolvers.shift()!([]);
}
}
}

View File

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

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"build": "next build --no-turbopack",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",

9036
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
ALTER TABLE "mesh"."file" ADD COLUMN "encrypted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD COLUMN "owner_pubkey" text;--> statement-breakpoint
CREATE TABLE "mesh"."file_key" (
"id" text PRIMARY KEY NOT NULL,
"file_id" text NOT NULL,
"peer_pubkey" text NOT NULL,
"sealed_key" text NOT NULL,
"granted_at" timestamp DEFAULT now() NOT NULL,
"granted_by_pubkey" text
);
--> statement-breakpoint
ALTER TABLE "mesh"."file_key" ADD CONSTRAINT "file_key_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "file_key_file_peer_idx" ON "mesh"."file_key" ("file_id","peer_pubkey");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "mesh"."context" ADD COLUMN "member_id" text;--> statement-breakpoint
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_member_id_member_id_fk" FOREIGN KEY ("member_id") REFERENCES "mesh"."member"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "context_mesh_member_idx" ON "mesh"."context" ("mesh_id","member_id");

View File

@@ -305,6 +305,8 @@ export const meshFile = meshSchema.table("file", {
minioKey: text().notNull(),
tags: text().array().default([]),
persistent: boolean().notNull().default(true),
encrypted: boolean().notNull().default(false),
ownerPubkey: text(),
uploadedByName: text(),
uploadedByMember: text().references(() => meshMember.id),
targetSpec: text(), // null = entire mesh
@@ -328,24 +330,60 @@ export const meshFileAccess = meshSchema.table("file_access", {
});
/**
* Per-peer context snapshot. Each peer (presence) has at most one context
* Per-peer encrypted symmetric keys for E2E encrypted files.
* The file body is encrypted with a random key (Kf); Kf is sealed
* (crypto_box_seal) to each authorized peer's X25519 pubkey and stored here.
*/
export const meshFileKey = meshSchema.table("file_key", {
id: text().primaryKey().notNull().$defaultFn(generateId),
fileId: text()
.references(() => meshFile.id, { onDelete: "cascade" })
.notNull(),
peerPubkey: text().notNull(),
sealedKey: text().notNull(),
grantedAt: timestamp().defaultNow().notNull(),
grantedByPubkey: text(),
});
export const meshFileKeyRelations = relations(meshFileKey, ({ one }) => ({
file: one(meshFile, {
fields: [meshFileKey.fileId],
references: [meshFile.id],
}),
}));
/**
* Per-peer context snapshot. Each peer (member) has at most one context
* entry per mesh, upserted on each share_context call. Allows peers to
* discover what others are working on, which files they've read, and
* key findings — without sending a direct message.
*
* `memberId` is the stable upsert key (survives reconnects). `presenceId`
* is kept for backwards-compat but is nullable — new rows should always
* populate `memberId`. The unique index on (meshId, memberId) prevents
* stale rows from accumulating when a session reconnects with a new
* ephemeral presenceId.
*/
export const meshContext = meshSchema.table("context", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
peerName: text(),
summary: text().notNull(),
filesRead: text().array().default([]),
keyFindings: text().array().default([]),
tags: text().array().default([]),
updatedAt: timestamp().defaultNow().notNull(),
});
export const meshContext = meshSchema.table(
"context",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
memberId: text().references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }),
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
peerName: text(),
summary: text().notNull(),
filesRead: text().array().default([]),
keyFindings: text().array().default([]),
tags: text().array().default([]),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [
uniqueIndex("context_mesh_member_idx").on(table.meshId, table.memberId),
],
);
/**
* Mesh-scoped task board. Peers can create tasks, claim them, and mark
@@ -531,6 +569,10 @@ export type SelectMeshFile = typeof meshFile.$inferSelect;
export type InsertMeshFile = typeof meshFile.$inferInsert;
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;
export const selectMeshFileKeySchema = createSelectSchema(meshFileKey);
export const insertMeshFileKeySchema = createInsertSchema(meshFileKey);
export type SelectMeshFileKey = typeof meshFileKey.$inferSelect;
export type InsertMeshFileKey = typeof meshFileKey.$inferInsert;
export const selectMeshContextSchema = createSelectSchema(meshContext);
export const insertMeshContextSchema = createInsertSchema(meshContext);
export const selectMeshTaskSchema = createSelectSchema(meshTask);