Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
663f800b4b | ||
|
|
2557235c68 | ||
|
|
a987e9e27b | ||
|
|
ff86db615f | ||
|
|
4aa61b40e2 | ||
|
|
4afe365c00 | ||
|
|
92bb276a3e | ||
|
|
af8f8ed1f9 | ||
|
|
c8682dd700 | ||
|
|
004602a83c | ||
|
|
2a2aac3622 |
@@ -265,6 +265,23 @@ export async function refreshQueueDepth(): Promise<void> {
|
|||||||
metrics.queueDepth.set(Number(row?.n ?? 0));
|
metrics.queueDepth.set(Number(row?.n ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep stale presences: mark as disconnected if last_ping_at is older
|
||||||
|
* than 90s (3 missed pings at the 30s interval = dead session).
|
||||||
|
*/
|
||||||
|
export async function sweepStalePresences(): Promise<void> {
|
||||||
|
const cutoff = new Date(Date.now() - 90_000); // 3 missed pings
|
||||||
|
await db
|
||||||
|
.update(presence)
|
||||||
|
.set({ disconnectedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(presence.disconnectedAt),
|
||||||
|
lt(presence.lastPingAt, cutoff),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Sweep expired pending_status entries. */
|
/** Sweep expired pending_status entries. */
|
||||||
export async function sweepPendingStatuses(): Promise<void> {
|
export async function sweepPendingStatuses(): Promise<void> {
|
||||||
const cutoff = new Date(Date.now() - PENDING_TTL_MS);
|
const cutoff = new Date(Date.now() - PENDING_TTL_MS);
|
||||||
@@ -307,6 +324,8 @@ export async function refreshStatusFromJsonl(
|
|||||||
export interface ConnectParams {
|
export interface ConnectParams {
|
||||||
memberId: string;
|
memberId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
sessionPubkey?: string;
|
||||||
|
displayName?: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
@@ -321,6 +340,8 @@ export async function connectPresence(
|
|||||||
.values({
|
.values({
|
||||||
memberId: params.memberId,
|
memberId: params.memberId,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
sessionPubkey: params.sessionPubkey ?? null,
|
||||||
|
displayName: params.displayName ?? null,
|
||||||
pid: params.pid,
|
pid: params.pid,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -369,8 +390,10 @@ export async function listPeersInMesh(
|
|||||||
> {
|
> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
pubkey: memberTable.peerPubkey,
|
memberPubkey: memberTable.peerPubkey,
|
||||||
displayName: memberTable.displayName,
|
sessionPubkey: presence.sessionPubkey,
|
||||||
|
memberDisplayName: memberTable.displayName,
|
||||||
|
presenceDisplayName: presence.displayName,
|
||||||
status: presence.status,
|
status: presence.status,
|
||||||
summary: presence.summary,
|
summary: presence.summary,
|
||||||
sessionId: presence.sessionId,
|
sessionId: presence.sessionId,
|
||||||
@@ -385,7 +408,15 @@ export async function listPeersInMesh(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(presence.connectedAt));
|
.orderBy(asc(presence.connectedAt));
|
||||||
return rows;
|
// Prefer session pubkey for routing, session displayName for display.
|
||||||
|
return rows.map((r) => ({
|
||||||
|
pubkey: r.sessionPubkey || r.memberPubkey,
|
||||||
|
displayName: r.presenceDisplayName || r.memberDisplayName,
|
||||||
|
status: r.status,
|
||||||
|
summary: r.summary,
|
||||||
|
sessionId: r.sessionId,
|
||||||
|
connectedAt: r.connectedAt,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the summary text on a presence row. */
|
/** Update the summary text on a presence row. */
|
||||||
@@ -404,6 +435,7 @@ export async function setSummary(
|
|||||||
export interface QueueParams {
|
export interface QueueParams {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
senderMemberId: string;
|
senderMemberId: string;
|
||||||
|
senderSessionPubkey?: string;
|
||||||
targetSpec: string;
|
targetSpec: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
@@ -418,6 +450,7 @@ export async function queueMessage(params: QueueParams): Promise<string> {
|
|||||||
.values({
|
.values({
|
||||||
meshId: params.meshId,
|
meshId: params.meshId,
|
||||||
senderMemberId: params.senderMemberId,
|
senderMemberId: params.senderMemberId,
|
||||||
|
senderSessionPubkey: params.senderSessionPubkey ?? null,
|
||||||
targetSpec: params.targetSpec,
|
targetSpec: params.targetSpec,
|
||||||
priority: params.priority,
|
priority: params.priority,
|
||||||
nonce: params.nonce,
|
nonce: params.nonce,
|
||||||
@@ -458,6 +491,8 @@ export async function drainForMember(
|
|||||||
_memberId: string,
|
_memberId: string,
|
||||||
memberPubkey: string,
|
memberPubkey: string,
|
||||||
status: PeerStatus,
|
status: PeerStatus,
|
||||||
|
sessionPubkey?: string,
|
||||||
|
excludeSenderSessionPubkey?: string,
|
||||||
): Promise<
|
): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -498,14 +533,15 @@ export async function drainForMember(
|
|||||||
WHERE mesh_id = ${meshId}
|
WHERE mesh_id = ${meshId}
|
||||||
AND delivered_at IS NULL
|
AND delivered_at IS NULL
|
||||||
AND priority::text IN (${priorityList})
|
AND priority::text IN (${priorityList})
|
||||||
AND (target_spec = ${memberPubkey} OR target_spec = '*')
|
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``})
|
||||||
|
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
)
|
)
|
||||||
AND m.id = mq.sender_member_id
|
AND m.id = mq.sender_member_id
|
||||||
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
||||||
mq.created_at, mq.sender_member_id,
|
mq.created_at, mq.sender_member_id,
|
||||||
m.peer_pubkey AS sender_pubkey
|
COALESCE(mq.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey
|
||||||
)
|
)
|
||||||
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
|
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
|
||||||
`);
|
`);
|
||||||
@@ -536,6 +572,7 @@ export async function drainForMember(
|
|||||||
|
|
||||||
let ttlTimer: ReturnType<typeof setInterval> | null = null;
|
let ttlTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let pendingTimer: ReturnType<typeof setInterval> | null = null;
|
let pendingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
/** Start background sweepers. Idempotent. */
|
/** Start background sweepers. Idempotent. */
|
||||||
export function startSweepers(): void {
|
export function startSweepers(): void {
|
||||||
@@ -548,14 +585,21 @@ export function startSweepers(): void {
|
|||||||
console.error("[broker] pending sweep:", e),
|
console.error("[broker] pending sweep:", e),
|
||||||
);
|
);
|
||||||
}, PENDING_SWEEP_INTERVAL_MS);
|
}, PENDING_SWEEP_INTERVAL_MS);
|
||||||
|
staleTimer = setInterval(() => {
|
||||||
|
sweepStalePresences().catch((e) =>
|
||||||
|
console.error("[broker] stale presence sweep:", e),
|
||||||
|
);
|
||||||
|
}, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop background sweepers and mark all active presences disconnected. */
|
/** Stop background sweepers and mark all active presences disconnected. */
|
||||||
export async function stopSweepers(): Promise<void> {
|
export async function stopSweepers(): Promise<void> {
|
||||||
if (ttlTimer) clearInterval(ttlTimer);
|
if (ttlTimer) clearInterval(ttlTimer);
|
||||||
if (pendingTimer) clearInterval(pendingTimer);
|
if (pendingTimer) clearInterval(pendingTimer);
|
||||||
|
if (staleTimer) clearInterval(staleTimer);
|
||||||
ttlTimer = null;
|
ttlTimer = null;
|
||||||
pendingTimer = null;
|
pendingTimer = null;
|
||||||
|
staleTimer = null;
|
||||||
await db
|
await db
|
||||||
.update(presence)
|
.update(presence)
|
||||||
.set({ disconnectedAt: new Date() })
|
.set({ disconnectedAt: new Date() })
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ interface PeerConn {
|
|||||||
meshId: string;
|
meshId: string;
|
||||||
memberId: string;
|
memberId: string;
|
||||||
memberPubkey: string;
|
memberPubkey: string;
|
||||||
|
sessionPubkey: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +81,10 @@ function sendToPeer(presenceId: string, msg: WSServerMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybePushQueuedMessages(presenceId: string): Promise<void> {
|
async function maybePushQueuedMessages(
|
||||||
|
presenceId: string,
|
||||||
|
excludeSenderSessionPubkey?: string,
|
||||||
|
): Promise<void> {
|
||||||
const conn = connections.get(presenceId);
|
const conn = connections.get(presenceId);
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
const status = await refreshStatusFromJsonl(
|
const status = await refreshStatusFromJsonl(
|
||||||
@@ -93,6 +97,8 @@ async function maybePushQueuedMessages(presenceId: string): Promise<void> {
|
|||||||
conn.memberId,
|
conn.memberId,
|
||||||
conn.memberPubkey,
|
conn.memberPubkey,
|
||||||
status,
|
status,
|
||||||
|
conn.sessionPubkey ?? undefined,
|
||||||
|
excludeSenderSessionPubkey,
|
||||||
);
|
);
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const push: WSPushMessage = {
|
const push: WSPushMessage = {
|
||||||
@@ -400,6 +406,8 @@ async function handleHello(
|
|||||||
const presenceId = await connectPresence({
|
const presenceId = await connectPresence({
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
sessionId: hello.sessionId,
|
sessionId: hello.sessionId,
|
||||||
|
sessionPubkey: hello.sessionPubkey,
|
||||||
|
displayName: hello.displayName,
|
||||||
pid: hello.pid,
|
pid: hello.pid,
|
||||||
cwd: hello.cwd,
|
cwd: hello.cwd,
|
||||||
});
|
});
|
||||||
@@ -408,12 +416,14 @@ async function handleHello(
|
|||||||
meshId: hello.meshId,
|
meshId: hello.meshId,
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
memberPubkey: hello.pubkey,
|
memberPubkey: hello.pubkey,
|
||||||
|
sessionPubkey: hello.sessionPubkey ?? null,
|
||||||
cwd: hello.cwd,
|
cwd: hello.cwd,
|
||||||
});
|
});
|
||||||
incMeshCount(hello.meshId);
|
incMeshCount(hello.meshId);
|
||||||
|
const effectiveDisplayName = hello.displayName || member.displayName;
|
||||||
log.info("ws hello", {
|
log.info("ws hello", {
|
||||||
mesh_id: hello.meshId,
|
mesh_id: hello.meshId,
|
||||||
member: member.displayName,
|
member: effectiveDisplayName,
|
||||||
presence_id: presenceId,
|
presence_id: presenceId,
|
||||||
session_id: hello.sessionId,
|
session_id: hello.sessionId,
|
||||||
});
|
});
|
||||||
@@ -422,7 +432,7 @@ async function handleHello(
|
|||||||
// races the caller's closure assignment, causing subsequent client
|
// races the caller's closure assignment, causing subsequent client
|
||||||
// messages to fail the "no_hello" check.
|
// messages to fail the "no_hello" check.
|
||||||
void maybePushQueuedMessages(presenceId);
|
void maybePushQueuedMessages(presenceId);
|
||||||
return { presenceId, memberDisplayName: member.displayName };
|
return { presenceId, memberDisplayName: effectiveDisplayName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend(
|
async function handleSend(
|
||||||
@@ -432,6 +442,7 @@ async function handleSend(
|
|||||||
const messageId = await queueMessage({
|
const messageId = await queueMessage({
|
||||||
meshId: conn.meshId,
|
meshId: conn.meshId,
|
||||||
senderMemberId: conn.memberId,
|
senderMemberId: conn.memberId,
|
||||||
|
senderSessionPubkey: conn.sessionPubkey ?? undefined,
|
||||||
targetSpec: msg.targetSpec,
|
targetSpec: msg.targetSpec,
|
||||||
priority: msg.priority,
|
priority: msg.priority,
|
||||||
nonce: msg.nonce,
|
nonce: msg.nonce,
|
||||||
@@ -445,12 +456,21 @@ async function handleSend(
|
|||||||
};
|
};
|
||||||
conn.ws.send(JSON.stringify(ack));
|
conn.ws.send(JSON.stringify(ack));
|
||||||
|
|
||||||
// Fan-out over connected peers in the same mesh.
|
// Find sender's presenceId to exclude from fan-out.
|
||||||
|
let senderPresenceId: string | undefined;
|
||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
|
if (peer.ws === conn.ws) { senderPresenceId = pid; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-out over connected peers in the same mesh — skip sender.
|
||||||
|
for (const [pid, peer] of connections) {
|
||||||
|
if (pid === senderPresenceId) continue;
|
||||||
if (peer.meshId !== conn.meshId) continue;
|
if (peer.meshId !== conn.meshId) continue;
|
||||||
if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec)
|
if (msg.targetSpec !== "*"
|
||||||
|
&& peer.memberPubkey !== msg.targetSpec
|
||||||
|
&& peer.sessionPubkey !== msg.targetSpec)
|
||||||
continue;
|
continue;
|
||||||
void maybePushQueuedMessages(pid);
|
void maybePushQueuedMessages(pid, conn.sessionPubkey ?? undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface WSHelloMessage {
|
|||||||
meshId: string;
|
meshId: string;
|
||||||
memberId: string;
|
memberId: string;
|
||||||
pubkey: string; // must match mesh.member.peerPubkey
|
pubkey: string; // must match mesh.member.peerPubkey
|
||||||
|
sessionPubkey?: string; // ephemeral per-launch pubkey for message routing
|
||||||
|
displayName?: string; // optional override for this session
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.1.6",
|
"version": "0.1.16",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { parseInviteLink } from "../invite/parse";
|
|||||||
import { enrollWithBroker } from "../invite/enroll";
|
import { enrollWithBroker } from "../invite/enroll";
|
||||||
import { generateKeypair } from "../crypto/keypair";
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||||
import { hostname } from "node:os";
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { homedir, hostname } from "node:os";
|
||||||
|
import { env } from "../env";
|
||||||
|
|
||||||
export async function runJoin(args: string[]): Promise<void> {
|
export async function runJoin(args: string[]): Promise<void> {
|
||||||
const link = args[0];
|
const link = args[0];
|
||||||
@@ -78,6 +81,16 @@ export async function runJoin(args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
|
||||||
|
// 4b. Store invite token for per-session re-enrollment (launch --name).
|
||||||
|
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
|
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(inviteFile), { recursive: true });
|
||||||
|
writeFileSync(inviteFile, link, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — launch will fall back to shared identity.
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Report.
|
// 5. Report.
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -1,82 +1,296 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the
|
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||||
* claudemesh MCP server's `notifications/claude/channel` pushes get
|
|
||||||
* injected as system reminders mid-turn.
|
|
||||||
*
|
*
|
||||||
* Equivalent to:
|
* Flow:
|
||||||
* claude --dangerously-load-development-channels server:claudemesh [extra args]
|
* 1. Parse --name, --join, --mesh, --quiet flags
|
||||||
*
|
* 2. If --join: run join flow first (accepts token or URL)
|
||||||
* Any additional args (e.g. --model opus, --resume, -c) are passed
|
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||||
* through verbatim. Use --quiet to skip the informational banner.
|
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||||
|
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||||
|
* 6. On exit: cleanup tmpdir
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { tmpdir, hostname } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
import { loadConfig, getConfigPath } from "../state/config";
|
import { loadConfig, getConfigPath } from "../state/config";
|
||||||
|
import type { Config, JoinedMesh } from "../state/config";
|
||||||
|
|
||||||
function printBanner(): void {
|
// --- Arg parsing ---
|
||||||
|
|
||||||
|
interface LaunchArgs {
|
||||||
|
name: string | null;
|
||||||
|
joinLink: string | null;
|
||||||
|
meshSlug: string | null;
|
||||||
|
quiet: boolean;
|
||||||
|
skipPermConfirm: boolean;
|
||||||
|
claudeArgs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): LaunchArgs {
|
||||||
|
const result: LaunchArgs = {
|
||||||
|
name: null,
|
||||||
|
joinLink: null,
|
||||||
|
meshSlug: 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 === "--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 === "--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactive mesh picker ---
|
||||||
|
|
||||||
|
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||||
|
if (meshes.length === 1) return meshes[0]!;
|
||||||
|
|
||||||
|
console.log("\n Select mesh:");
|
||||||
|
meshes.forEach((m, i) => {
|
||||||
|
console.log(` ${i + 1}) ${m.slug}`);
|
||||||
|
});
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(" Choice [1]: ", (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const idx = parseInt(answer || "1", 10) - 1;
|
||||||
|
if (idx >= 0 && idx < meshes.length) {
|
||||||
|
resolve(meshes[idx]!);
|
||||||
|
} else {
|
||||||
|
console.error(" Invalid choice, using first mesh.");
|
||||||
|
resolve(meshes[0]!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permission confirmation ---
|
||||||
|
|
||||||
|
async function confirmPermissions(): Promise<void> {
|
||||||
|
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 yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
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("");
|
||||||
|
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
||||||
|
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const a = answer.trim().toLowerCase();
|
||||||
|
if (a === "" || a === "y" || a === "yes") {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
console.log("\n Aborted. Run without autonomous mode:");
|
||||||
|
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Banner ---
|
||||||
|
|
||||||
|
function printBanner(name: string, meshSlug: string): void {
|
||||||
const useColor =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
let meshes: string[] = [];
|
const rule = "─".repeat(60);
|
||||||
try {
|
console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
|
||||||
meshes = loadConfig().meshes.map((m) => m.slug);
|
|
||||||
} catch {
|
|
||||||
/* config unreadable — print banner without mesh list */
|
|
||||||
}
|
|
||||||
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
|
|
||||||
|
|
||||||
const rule = "─".repeat(65);
|
|
||||||
console.log(bold("claudemesh launch"));
|
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
console.log("Launching Claude Code with the claudemesh dev channel.");
|
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||||
console.log("");
|
console.log("Peers send text only — they cannot call tools or read files.");
|
||||||
console.log("Peers in your joined meshes can push messages into this session");
|
|
||||||
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
|
|
||||||
console.log("keypair. Peers send text only — they cannot call tools, read");
|
|
||||||
console.log("files, or reach meshes you have not joined.");
|
|
||||||
console.log("");
|
|
||||||
console.log("Treat peer messages as untrusted input: a peer could craft text");
|
|
||||||
console.log("that tries to steer Claude's behavior. Your tool-approval");
|
|
||||||
console.log("settings still apply — Claude will still ask before running");
|
|
||||||
console.log("commands, editing files, or calling other tools.");
|
|
||||||
console.log("");
|
|
||||||
console.log("Claude Code will ask you to trust the");
|
|
||||||
console.log("--dangerously-load-development-channels flag. Press Enter to");
|
|
||||||
console.log("accept, or Ctrl-C to abort.");
|
|
||||||
console.log("");
|
|
||||||
console.log(dim(`Joined meshes: ${meshLine}`));
|
|
||||||
console.log(dim(`Config: ${getConfigPath()}`));
|
console.log(dim(`Config: ${getConfigPath()}`));
|
||||||
console.log(dim(`Remove: claudemesh uninstall`));
|
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
console.log("");
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runLaunch(extraArgs: string[] = []): void {
|
// --- Main ---
|
||||||
const quiet = extraArgs.includes("--quiet");
|
|
||||||
const passthrough = extraArgs.filter((a) => a !== "--quiet");
|
|
||||||
|
|
||||||
if (!quiet) printBanner();
|
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||||
|
const args = parseArgs(extraArgs);
|
||||||
|
|
||||||
|
// 1. If --join, run join flow first.
|
||||||
|
if (args.joinLink) {
|
||||||
|
console.log("Joining mesh...");
|
||||||
|
const invite = await parseInviteLink(args.joinLink);
|
||||||
|
const keypair = await generateKeypair();
|
||||||
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
const enroll = await enrollWithBroker({
|
||||||
|
brokerWsUrl: invite.payload.broker_url,
|
||||||
|
inviteToken: invite.token,
|
||||||
|
invitePayload: invite.payload,
|
||||||
|
peerPubkey: keypair.publicKey,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
const config = loadConfig();
|
||||||
|
config.meshes = config.meshes.filter(
|
||||||
|
(m) => m.slug !== invite.payload.mesh_slug,
|
||||||
|
);
|
||||||
|
config.meshes.push({
|
||||||
|
meshId: invite.payload.mesh_id,
|
||||||
|
memberId: enroll.memberId,
|
||||||
|
slug: invite.payload.mesh_slug,
|
||||||
|
name: invite.payload.mesh_slug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: invite.payload.broker_url,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const { saveConfig } = await import("../state/config");
|
||||||
|
saveConfig(config);
|
||||||
|
console.log(
|
||||||
|
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load config, pick mesh.
|
||||||
|
const config = loadConfig();
|
||||||
|
if (config.meshes.length === 0) {
|
||||||
|
console.error(
|
||||||
|
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mesh: JoinedMesh;
|
||||||
|
if (args.meshSlug) {
|
||||||
|
const found = config.meshes.find((m) => m.slug === args.meshSlug);
|
||||||
|
if (!found) {
|
||||||
|
console.error(
|
||||||
|
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
mesh = found;
|
||||||
|
} else {
|
||||||
|
mesh = await pickMesh(config.meshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Session identity. The WS client auto-generates a per-session
|
||||||
|
// ephemeral keypair on connect (sent in hello as sessionPubkey).
|
||||||
|
// We just set the display name via env var.
|
||||||
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
|
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||||
|
const tmpBase = tmpdir();
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(tmpBase)) {
|
||||||
|
if (!entry.startsWith("claudemesh-")) continue;
|
||||||
|
const full = join(tmpBase, entry);
|
||||||
|
const age = Date.now() - statSync(full).mtimeMs;
|
||||||
|
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
|
||||||
|
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||||
|
const sessionConfig: Config = {
|
||||||
|
version: 1,
|
||||||
|
meshes: [mesh],
|
||||||
|
displayName,
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
join(tmpDir, "config.json"),
|
||||||
|
JSON.stringify(sessionConfig, null, 2) + "\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Banner + permission confirmation.
|
||||||
|
if (!args.quiet) {
|
||||||
|
printBanner(displayName, mesh.slug);
|
||||||
|
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||||
|
if (!args.skipPermConfirm) {
|
||||||
|
await confirmPermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
|
||||||
|
// Strip any user-supplied --dangerously flags to avoid duplicates.
|
||||||
|
const filtered: string[] = [];
|
||||||
|
for (let i = 0; i < args.claudeArgs.length; i++) {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|
||||||
|
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.push(args.claudeArgs[i]!);
|
||||||
|
}
|
||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
...passthrough,
|
"--dangerously-skip-permissions",
|
||||||
|
...filtered,
|
||||||
];
|
];
|
||||||
// Windows: npm global binaries are .cmd shims. Node's spawn without
|
|
||||||
// shell:true does not resolve PATHEXT, so we need shell:true on win32
|
|
||||||
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
|
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
const child = spawn("claude", claudeArgs, {
|
const child = spawn("claude", claudeArgs, {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: isWindows,
|
shell: isWindows,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||||
|
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 7. Cleanup on exit.
|
||||||
|
const cleanup = (): void => {
|
||||||
|
try {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
cleanup();
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
console.error(
|
console.error(
|
||||||
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code",
|
"✗ `claude` not found on PATH. Install Claude Code first.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||||
@@ -85,10 +299,15 @@ export function runLaunch(extraArgs: string[] = []): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
|
cleanup();
|
||||||
if (signal) {
|
if (signal) {
|
||||||
process.kill(process.pid, signal);
|
process.kill(process.pid, signal);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.exit(code ?? 0);
|
process.exit(code ?? 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup on parent signals too.
|
||||||
|
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
||||||
|
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI environment config.
|
* CLI environment config.
|
||||||
*
|
*
|
||||||
* Read once at startup. Overridable via env vars so users can point
|
* Read once at startup. Overridable via env vars so users can point
|
||||||
* at a self-hosted broker or a staging instance without rebuilding.
|
* at a self-hosted broker or a staging instance without rebuilding.
|
||||||
*/
|
*/
|
||||||
const envSchema = z.object({
|
|
||||||
CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"),
|
|
||||||
CLAUDEMESH_CONFIG_DIR: z.string().optional(),
|
|
||||||
CLAUDEMESH_DEBUG: z.coerce.boolean().default(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CliEnv = z.infer<typeof envSchema>;
|
export interface CliEnv {
|
||||||
|
CLAUDEMESH_BROKER_URL: string;
|
||||||
|
CLAUDEMESH_CONFIG_DIR: string | undefined;
|
||||||
|
CLAUDEMESH_DEBUG: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadEnv(): CliEnv {
|
export function loadEnv(): CliEnv {
|
||||||
const parsed = envSchema.safeParse(process.env);
|
return {
|
||||||
if (!parsed.success) {
|
CLAUDEMESH_BROKER_URL:
|
||||||
console.error("[claudemesh] invalid environment:");
|
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||||
console.error(z.treeifyError(parsed.error));
|
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
|
||||||
process.exit(1);
|
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
|
||||||
}
|
};
|
||||||
return parsed.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = loadEnv();
|
export const env = loadEnv();
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ Commands:
|
|||||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
install Register MCP + Stop/UserPromptSubmit status hooks
|
||||||
(add --no-hooks for bare MCP registration)
|
(add --no-hooks for bare MCP registration)
|
||||||
uninstall Remove MCP server + hooks
|
uninstall Remove MCP server + hooks
|
||||||
launch [args] Launch Claude Code with real-time push messages enabled
|
launch [opts] Launch Claude Code with real-time push messages
|
||||||
(add --quiet to skip the info banner; passes through
|
--name <name> Display name for this session
|
||||||
extra flags, e.g. --model, --resume)
|
--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
|
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||||
list Show all joined meshes
|
list Show all joined meshes
|
||||||
leave <slug> Leave a joined mesh
|
leave <slug> Leave a joined mesh
|
||||||
@@ -67,7 +70,7 @@ async function main(): Promise<void> {
|
|||||||
await runHook(args);
|
await runHook(args);
|
||||||
return;
|
return;
|
||||||
case "launch":
|
case "launch":
|
||||||
runLaunch(args);
|
await runLaunch(args);
|
||||||
return;
|
return;
|
||||||
case "join":
|
case "join":
|
||||||
await runJoin(args);
|
await runJoin(args);
|
||||||
|
|||||||
@@ -5,22 +5,19 @@
|
|||||||
* verification and one-time-use invite-token tracking land in Step 18.
|
* verification and one-time-use invite-token tracking land in Step 18.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ensureSodium } from "../crypto/keypair";
|
import { ensureSodium } from "../crypto/keypair";
|
||||||
|
|
||||||
const invitePayloadSchema = z.object({
|
export interface InvitePayload {
|
||||||
v: z.literal(1),
|
v: 1;
|
||||||
mesh_id: z.string().min(1),
|
mesh_id: string;
|
||||||
mesh_slug: z.string().min(1),
|
mesh_slug: string;
|
||||||
broker_url: z.string().min(1),
|
broker_url: string;
|
||||||
expires_at: z.number().int().positive(),
|
expires_at: number;
|
||||||
mesh_root_key: z.string().min(1),
|
mesh_root_key: string;
|
||||||
role: z.enum(["admin", "member"]),
|
role: "admin" | "member";
|
||||||
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
|
owner_pubkey: string;
|
||||||
signature: z.string().regex(/^[0-9a-f]{128}$/i),
|
signature: string;
|
||||||
});
|
}
|
||||||
|
|
||||||
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
|
||||||
|
|
||||||
export interface ParsedInvite {
|
export interface ParsedInvite {
|
||||||
payload: InvitePayload;
|
payload: InvitePayload;
|
||||||
@@ -28,6 +25,21 @@ export interface ParsedInvite {
|
|||||||
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
|
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePayload(obj: unknown): InvitePayload {
|
||||||
|
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
if (o.v !== 1) throw new Error("invite payload: v must be 1");
|
||||||
|
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
|
||||||
|
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
|
||||||
|
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
|
||||||
|
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
|
||||||
|
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
|
||||||
|
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
|
||||||
|
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
|
||||||
|
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
|
||||||
|
return o as unknown as InvitePayload;
|
||||||
|
}
|
||||||
|
|
||||||
/** Canonical invite bytes — must match broker's canonicalInvite(). */
|
/** Canonical invite bytes — must match broker's canonicalInvite(). */
|
||||||
export function canonicalInvite(p: {
|
export function canonicalInvite(p: {
|
||||||
v: number;
|
v: number;
|
||||||
@@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = invitePayloadSchema.safeParse(obj);
|
const payload = validatePayload(obj);
|
||||||
if (!parsed.success) {
|
|
||||||
throw new Error(
|
|
||||||
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiry check (unix seconds).
|
// Expiry check (unix seconds).
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
if (parsed.data.expires_at < nowSeconds) {
|
if (payload.expires_at < nowSeconds) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
|
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the ed25519 signature against the embedded owner_pubkey.
|
// Verify the ed25519 signature against the embedded owner_pubkey.
|
||||||
// Client-side verification gives immediate feedback on tampered
|
|
||||||
// links; broker re-verifies authoritatively on /join.
|
|
||||||
const s = await ensureSodium();
|
const s = await ensureSodium();
|
||||||
const canonical = canonicalInvite({
|
const canonical = canonicalInvite({
|
||||||
v: parsed.data.v,
|
v: payload.v,
|
||||||
mesh_id: parsed.data.mesh_id,
|
mesh_id: payload.mesh_id,
|
||||||
mesh_slug: parsed.data.mesh_slug,
|
mesh_slug: payload.mesh_slug,
|
||||||
broker_url: parsed.data.broker_url,
|
broker_url: payload.broker_url,
|
||||||
expires_at: parsed.data.expires_at,
|
expires_at: payload.expires_at,
|
||||||
mesh_root_key: parsed.data.mesh_root_key,
|
mesh_root_key: payload.mesh_root_key,
|
||||||
role: parsed.data.role,
|
role: payload.role,
|
||||||
owner_pubkey: parsed.data.owner_pubkey,
|
owner_pubkey: payload.owner_pubkey,
|
||||||
});
|
});
|
||||||
const sigOk = (() => {
|
const sigOk = (() => {
|
||||||
try {
|
try {
|
||||||
return s.crypto_sign_verify_detached(
|
return s.crypto_sign_verify_detached(
|
||||||
s.from_hex(parsed.data.signature),
|
s.from_hex(payload.signature),
|
||||||
s.from_string(canonical),
|
s.from_string(canonical),
|
||||||
s.from_hex(parsed.data.owner_pubkey),
|
s.from_hex(payload.owner_pubkey),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
|||||||
throw new Error("invite signature invalid (link tampered?)");
|
throw new Error("invite signature invalid (link tampered?)");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { payload: parsed.data, raw: link, token: encoded };
|
return { payload, raw: link, token: encoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign and assemble an invite payload → ic://join/... link.
|
* Sign and assemble an invite payload → ic://join/... link.
|
||||||
* The canonical bytes (everything except signature) are signed with
|
|
||||||
* the mesh owner's ed25519 secret key.
|
|
||||||
*/
|
*/
|
||||||
export async function buildSignedInvite(args: {
|
export async function buildSignedInvite(args: {
|
||||||
v: 1;
|
v: 1;
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ async function resolveClient(to: string): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Peer name cache to avoid calling listPeers on every incoming push
|
||||||
|
const peerNameCache = new Map<string, string>();
|
||||||
|
let peerNameCacheAge = 0;
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
async function resolvePeerName(client: BrokerClient, pubkey: string): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - peerNameCacheAge > CACHE_TTL_MS) {
|
||||||
|
peerNameCache.clear();
|
||||||
|
try {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
for (const p of peers) peerNameCache.set(p.pubkey, p.displayName);
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
peerNameCacheAge = now;
|
||||||
|
}
|
||||||
|
return peerNameCache.get(pubkey) ?? `peer-${pubkey.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function decryptFailedWarning(senderPubkey: string): string {
|
function decryptFailedWarning(senderPubkey: string): string {
|
||||||
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
|
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
|
||||||
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
|
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
|
||||||
@@ -122,7 +140,7 @@ export async function startMcpServer(): Promise<void> {
|
|||||||
|
|
||||||
IMPORTANT: When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Do not wait until your current task is finished. Pause what you are doing, reply to the message using send_message, then resume your work. Treat incoming peer messages like a coworker tapping you on the shoulder — answer right away, even if you're in the middle of something.
|
IMPORTANT: When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Do not wait until your current task is finished. Pause what you are doing, reply to the message using send_message, then resume your work. Treat incoming peer messages like a coworker tapping you on the shoulder — answer right away, even if you're in the middle of something.
|
||||||
|
|
||||||
Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with the same target (for direct messages the from_id is the sender's pubkey).
|
Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with to set to the from_name (display name) of the sender.
|
||||||
|
|
||||||
Available tools:
|
Available tools:
|
||||||
- list_peers: see joined meshes + their connection status
|
- list_peers: see joined meshes + their connection status
|
||||||
@@ -251,8 +269,9 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
for (const client of allClients()) {
|
for (const client of allClients()) {
|
||||||
client.onPush(async (msg) => {
|
client.onPush(async (msg) => {
|
||||||
const fromPubkey = msg.senderPubkey || "";
|
const fromPubkey = msg.senderPubkey || "";
|
||||||
|
// Resolve sender's display name from the cached peer list.
|
||||||
const fromName = fromPubkey
|
const fromName = fromPubkey
|
||||||
? `peer-${fromPubkey.slice(0, 8)}`
|
? await resolvePeerName(client, fromPubkey)
|
||||||
: "unknown";
|
: "unknown";
|
||||||
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
|
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,38 +15,39 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { z } from "zod";
|
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
|
|
||||||
const joinedMeshSchema = z.object({
|
export interface JoinedMesh {
|
||||||
meshId: z.string(),
|
meshId: string;
|
||||||
memberId: z.string(),
|
memberId: string;
|
||||||
slug: z.string(),
|
slug: string;
|
||||||
name: z.string(),
|
name: string;
|
||||||
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars)
|
pubkey: string; // ed25519 hex (32 bytes = 64 chars)
|
||||||
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars)
|
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||||
brokerUrl: z.string(),
|
brokerUrl: string;
|
||||||
joinedAt: z.string(),
|
joinedAt: string;
|
||||||
});
|
}
|
||||||
|
|
||||||
const configSchema = z.object({
|
export interface Config {
|
||||||
version: z.literal(1).default(1),
|
version: 1;
|
||||||
meshes: z.array(joinedMeshSchema).default([]),
|
meshes: JoinedMesh[];
|
||||||
});
|
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||||
|
}
|
||||||
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
|
|
||||||
export type Config = z.infer<typeof configSchema>;
|
|
||||||
|
|
||||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
if (!existsSync(CONFIG_PATH)) {
|
if (!existsSync(CONFIG_PATH)) {
|
||||||
return configSchema.parse({ version: 1, meshes: [] });
|
return { version: 1, meshes: [] };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
||||||
return configSchema.parse(JSON.parse(raw));
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||||
|
return { version: 1, meshes: [] };
|
||||||
|
}
|
||||||
|
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
isDirectTarget,
|
isDirectTarget,
|
||||||
} from "../crypto/envelope";
|
} from "../crypto/envelope";
|
||||||
import { signHello } from "../crypto/hello-sig";
|
import { signHello } from "../crypto/hello-sig";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
export type Priority = "now" | "next" | "low";
|
export type Priority = "now" | "next" | "low";
|
||||||
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||||
@@ -74,6 +75,8 @@ export class BrokerClient {
|
|||||||
private pushHandlers = new Set<PushHandler>();
|
private pushHandlers = new Set<PushHandler>();
|
||||||
private pushBuffer: InboundPush[] = [];
|
private pushBuffer: InboundPush[] = [];
|
||||||
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
|
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
|
||||||
|
private sessionPubkey: string | null = null;
|
||||||
|
private sessionSecretKey: string | null = null;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private reconnectAttempt = 0;
|
private reconnectAttempt = 0;
|
||||||
private helloTimer: NodeJS.Timeout | null = null;
|
private helloTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -83,6 +86,7 @@ export class BrokerClient {
|
|||||||
private mesh: JoinedMesh,
|
private mesh: JoinedMesh,
|
||||||
private opts: {
|
private opts: {
|
||||||
onStatusChange?: (status: ConnStatus) => void;
|
onStatusChange?: (status: ConnStatus) => void;
|
||||||
|
displayName?: string;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {}
|
) {}
|
||||||
@@ -109,8 +113,15 @@ export class BrokerClient {
|
|||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const onOpen = async (): Promise<void> => {
|
const onOpen = async (): Promise<void> => {
|
||||||
this.debug("ws open → signing + sending hello");
|
this.debug("ws open → generating session keypair + signing hello");
|
||||||
try {
|
try {
|
||||||
|
// Only generate session keypair on first connect, not reconnects
|
||||||
|
if (!this.sessionPubkey) {
|
||||||
|
const sessionKP = await generateKeypair();
|
||||||
|
this.sessionPubkey = sessionKP.publicKey;
|
||||||
|
this.sessionSecretKey = sessionKP.secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
const { timestamp, signature } = await signHello(
|
const { timestamp, signature } = await signHello(
|
||||||
this.mesh.meshId,
|
this.mesh.meshId,
|
||||||
this.mesh.memberId,
|
this.mesh.memberId,
|
||||||
@@ -123,6 +134,8 @@ export class BrokerClient {
|
|||||||
meshId: this.mesh.meshId,
|
meshId: this.mesh.meshId,
|
||||||
memberId: this.mesh.memberId,
|
memberId: this.mesh.memberId,
|
||||||
pubkey: this.mesh.pubkey,
|
pubkey: this.mesh.pubkey,
|
||||||
|
sessionPubkey: this.sessionPubkey,
|
||||||
|
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || this.opts.displayName || undefined,
|
||||||
sessionId: `${process.pid}-${Date.now()}`,
|
sessionId: `${process.pid}-${Date.now()}`,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -202,7 +215,7 @@ export class BrokerClient {
|
|||||||
const env = await encryptDirect(
|
const env = await encryptDirect(
|
||||||
message,
|
message,
|
||||||
targetSpec,
|
targetSpec,
|
||||||
this.mesh.secretKey,
|
this.sessionSecretKey ?? this.mesh.secretKey,
|
||||||
);
|
);
|
||||||
nonce = env.nonce;
|
nonce = env.nonce;
|
||||||
ciphertext = env.ciphertext;
|
ciphertext = env.ciphertext;
|
||||||
@@ -348,7 +361,7 @@ export class BrokerClient {
|
|||||||
plaintext = await decryptDirect(
|
plaintext = await decryptDirect(
|
||||||
{ nonce, ciphertext },
|
{ nonce, ciphertext },
|
||||||
senderPubkey,
|
senderPubkey,
|
||||||
this.mesh.secretKey,
|
this.sessionSecretKey ?? this.mesh.secretKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Legacy/broadcast path: no senderPubkey means the message
|
// Legacy/broadcast path: no senderPubkey means the message
|
||||||
@@ -365,6 +378,19 @@ export class BrokerClient {
|
|||||||
plaintext = null;
|
plaintext = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback: if direct decrypt failed, try plaintext base64 decode.
|
||||||
|
// This handles broadcasts and key mismatches gracefully.
|
||||||
|
if (plaintext === null && ciphertext) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||||
|
// Sanity check: valid UTF-8 text (not binary garbage)
|
||||||
|
if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) {
|
||||||
|
plaintext = decoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
plaintext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const push: InboundPush = {
|
const push: InboundPush = {
|
||||||
messageId: String(msg.messageId ?? ""),
|
messageId: String(msg.messageId ?? ""),
|
||||||
meshId: String(msg.meshId ?? ""),
|
meshId: String(msg.meshId ?? ""),
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import type { Config, JoinedMesh } from "../state/config";
|
|||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
|
|
||||||
const clients = new Map<string, BrokerClient>();
|
const clients = new Map<string, BrokerClient>();
|
||||||
|
let configDisplayName: string | undefined;
|
||||||
|
|
||||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||||
const existing = clients.get(mesh.meshId);
|
const existing = clients.get(mesh.meshId);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG });
|
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG, displayName: configDisplayName });
|
||||||
clients.set(mesh.meshId, client);
|
clients.set(mesh.meshId, client);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -29,6 +30,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
|
|
||||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||||
export async function startClients(config: Config): Promise<void> {
|
export async function startClients(config: Config): Promise<void> {
|
||||||
|
configDisplayName = config.displayName;
|
||||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mesh"."presence" ADD COLUMN "display_name" text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mesh"."presence" ADD COLUMN "session_pubkey" text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mesh"."message_queue" ADD COLUMN "sender_session_pubkey" text;
|
||||||
@@ -192,6 +192,8 @@ export const presence = meshSchema.table("presence", {
|
|||||||
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
sessionId: text().notNull(),
|
sessionId: text().notNull(),
|
||||||
|
sessionPubkey: text(),
|
||||||
|
displayName: text(),
|
||||||
pid: integer().notNull(),
|
pid: integer().notNull(),
|
||||||
cwd: text().notNull(),
|
cwd: text().notNull(),
|
||||||
status: presenceStatusEnum().notNull().default("idle"),
|
status: presenceStatusEnum().notNull().default("idle"),
|
||||||
@@ -220,6 +222,7 @@ export const messageQueue = meshSchema.table("message_queue", {
|
|||||||
senderMemberId: text()
|
senderMemberId: text()
|
||||||
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
senderSessionPubkey: text(),
|
||||||
targetSpec: text().notNull(),
|
targetSpec: text().notNull(),
|
||||||
priority: messagePriorityEnum().notNull().default("next"),
|
priority: messagePriorityEnum().notNull().default("next"),
|
||||||
nonce: text().notNull(),
|
nonce: text().notNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user