12 Commits

Author SHA1 Message Date
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
12 changed files with 9331 additions and 185 deletions

View File

@@ -1302,11 +1302,28 @@ export async function drainForMember(
); );
// Build group target matching: @all (broadcast alias) + @<groupname> // 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"]; const groupTargets = ["@all"];
if (memberGroups) { if (memberGroups) {
const seen = new Set<string>();
for (const g of memberGroups) { 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( const groupTargetList = sql.raw(
@@ -1337,7 +1354,7 @@ export async function drainForMember(
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 = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList})) 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 ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
) )

View File

@@ -23,7 +23,7 @@ const envSchema = z.object({
MINIO_ENDPOINT: z.string().default("minio:9000"), MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"), MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"), 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"), QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"), NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"), NEO4J_USER: z.string().default("neo4j"),

View File

@@ -123,7 +123,10 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey?: string, excludeSenderSessionPubkey?: string,
): Promise<void> { ): Promise<void> {
const conn = connections.get(presenceId); 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( const status = await refreshStatusFromJsonl(
presenceId, presenceId,
conn.cwd, conn.cwd,
@@ -138,6 +141,13 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey, excludeSenderSessionPubkey,
conn.groups.map((g) => g.name), 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) { for (const m of messages) {
const push: WSPushMessage = { const push: WSPushMessage = {
type: "push", type: "push",
@@ -435,16 +445,20 @@ function handleUploadPost(
: undefined, : 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 dbFileId = await uploadFile({ const dbFileId = await uploadFile({
meshId, meshId,
name: fileName, name: fileName,
sizeBytes: body.length, sizeBytes: body.length,
mimeType: (req.headers["content-type"] as string) || undefined, mimeType: (req.headers["content-type"] as string) || undefined,
minioKey, minioKey,
tags, tags: Array.isArray(tags) ? tags : [],
persistent, persistent,
uploadedByMember: memberId, uploadedByName: memberId || undefined,
uploadedByMember: undefined,
targetSpec: targetSpec || undefined, targetSpec: targetSpec || undefined,
}); });
@@ -1658,6 +1672,9 @@ function handleConnection(ws: WebSocket): void {
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`); 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 myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
const peerConn = connections.get(presenceId); 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, { sendToPeer(presenceId, {
type: "mesh_info_result", type: "mesh_info_result",
mesh: conn.meshId, mesh: conn.meshId,
@@ -1670,7 +1687,7 @@ function handleConnection(ws: WebSocket): void {
streams: streams.map(s => s.name), streams: streams.map(s => s.name),
tables: tables.map((t: any) => t.name), tables: tables.map((t: any) => t.name),
collections: [], collections: [],
yourName: peerConn?.groups?.[0]?.name ?? "unknown", yourName: selfPeer?.displayName ?? "unknown",
yourGroups: peerConn?.groups ?? [], yourGroups: peerConn?.groups ?? [],
}); });
log.info("ws mesh_info", { presence_id: presenceId }); log.info("ws mesh_info", { presence_id: presenceId });

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.5.6", "version": "0.6.0",
"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",
@@ -47,6 +47,7 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15", "libsodium-wrappers": "0.7.15",
"ws": "8.20.0", "ws": "8.20.0",
"zod": "4.1.13" "zod": "4.1.13"

View File

@@ -1,9 +1,12 @@
/** /**
* `claudemesh launch` — spawn `claude` with peer mesh identity. * `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: * Flow:
* 1. Parse --name, --join, --mesh, --quiet flags * 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first (accepts token or URL) * 2. If --join: run join flow first
* 3. Load config → pick mesh (auto if 1, interactive picker if >1) * 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection) * 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME * 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 { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config"; import type { Config, JoinedMesh, GroupEntry } from "../state/config";
// --- Arg parsing --- // Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
interface LaunchArgs { name?: string;
name: string | null; role?: string;
role: string | null; groups?: string;
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member" join?: string;
joinLink: string | null; mesh?: string;
meshSlug: string | null; "message-mode"?: string;
messageMode: "push" | "inbox" | "off" | null; "system-prompt"?: string;
quiet: boolean; yes?: boolean;
skipPermConfirm: boolean; quiet?: 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;
} }
// --- Interactive mesh picker --- // --- Interactive mesh picker ---
@@ -206,8 +153,26 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
// --- Main --- // --- Main ---
export async function runLaunch(extraArgs: string[]): Promise<void> { export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs); // 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. // 1. If --join, run join flow first.
if (args.joinLink) { if (args.joinLink) {
@@ -318,6 +283,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
version: 1, version: 1,
meshes: [mesh], meshes: [mesh],
displayName, displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode, messageMode,
}; };
@@ -351,6 +317,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
"--dangerously-load-development-channels", "--dangerously-load-development-channels",
"server:claudemesh", "server:claudemesh",
"--dangerously-skip-permissions", "--dangerously-skip-permissions",
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered, ...filtered,
]; ];
@@ -362,6 +329,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
...process.env, ...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName, CLAUDEMESH_DISPLAY_NAME: displayName,
...(role ? { CLAUDEMESH_ROLE: role } : {}),
}, },
}); });

View File

@@ -1,13 +1,15 @@
/** /**
* claudemesh-cli entry point. * 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: * Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport) * - `claudemesh mcp` → MCP server (stdio transport)
* - `claudemesh <subcommand>` → CLI subcommand * - `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 { startMcpServer } from "./mcp/server";
import { runInstall, runUninstall } from "./commands/install"; import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join"; import { runJoin } from "./commands/join";
@@ -21,96 +23,152 @@ import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome"; import { runWelcome } from "./commands/welcome";
import { VERSION } from "./version"; import { VERSION } from "./version";
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions const launch = defineCommand({
meta: {
Usage: name: "launch",
claudemesh <command> [args] description: "Launch Claude Code connected to a mesh with real-time peer messaging",
},
Commands: args: {
install Register MCP + Stop/UserPromptSubmit status hooks name: {
(add --no-hooks for bare MCP registration) type: "string",
uninstall Remove MCP server + hooks description: "Display name for this session",
launch [opts] Launch Claude Code with real-time push messages },
--name <name> Display name for this session role: {
--mesh <slug> Select mesh (picker if >1, omitted) type: "string",
--join <url> Join a mesh before launching description: "Role tag (dev, lead, analyst — free-form)",
--quiet Skip the info banner },
-- <args> Pass remaining args to claude groups: {
join <url> Join a mesh via https://claudemesh.com/join/... URL type: "string",
list Show all joined meshes description: 'Groups to join: "group:role,group2" — colon sets role. Hierarchy via slash: "eng/frontend:lead"',
leave <slug> Leave a joined mesh },
status Health report: broker reachability per joined mesh mesh: {
doctor Diagnostic checks (install, config, keypairs, PATH) type: "string",
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow) description: "Select mesh by slug (interactive picker if omitted and >1 joined)",
mcp Start MCP server (stdio) — invoked by Claude Code },
--help, -h Show this help join: {
--version, -v Show the CLI version type: "string",
description: "Join a mesh via invite URL before launching",
Environment: },
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws) "message-mode": {
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/) type: "string",
CLAUDEMESH_DEBUG=1 Verbose logging description: "push (default) | inbox | off — controls how peer messages are delivered",
`; },
"system-prompt": {
const cmd = process.argv[2]; type: "string",
const args = process.argv.slice(3); description: "Set Claude's system prompt for this session",
},
async function main(): Promise<void> { yes: {
switch (cmd) { type: "boolean",
case "mcp": alias: "y",
await startMcpServer(); description: "Skip permission confirmation",
return; default: false,
case "install": },
runInstall(args); quiet: {
return; type: "boolean",
case "uninstall": description: "Skip banner and all interactive prompts",
runUninstall(); default: false,
return; },
case "hook": },
await runHook(args); run({ args, rawArgs }) {
return; // Forward to the existing launch runner, preserving -- passthrough to claude.
case "launch": return runLaunch(args, rawArgs);
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 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 config = loadConfig();
const myName = config.displayName ?? "unnamed"; 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 myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push"; const messageMode = config.messageMode ?? "push";
@@ -141,7 +142,7 @@ export async function startMcpServer(): Promise<void> {
tools: {}, tools: {},
}, },
instructions: `## Identity 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 ## 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. 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.
@@ -445,11 +446,14 @@ Your message mode is "${messageMode}".
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true); if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0]; const client = allClients()[0];
if (!client) return text("share_file: not connected", true); if (!client) return text("share_file: not connected", true);
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, { try {
name: fileName, tags, persistent: 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})`); 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": { case "get_file": {
@@ -856,7 +860,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 => { const shutdown = (): void => {
clearInterval(keepalive);
stopAll(); stopAll();
process.exit(0); process.exit(0);
}; };

View File

@@ -37,6 +37,7 @@ export interface Config {
version: 1; version: 1;
meshes: JoinedMesh[]; meshes: JoinedMesh[];
displayName?: string; // per-session override, written by `claudemesh launch --name` displayName?: string; // per-session override, written by `claudemesh launch --name`
role?: string; // per-session role tag (display + hello)
groups?: GroupEntry[]; groups?: GroupEntry[];
messageMode?: "push" | "inbox" | "off"; messageMode?: "push" | "inbox" | "off";
} }
@@ -54,7 +55,7 @@ export function loadConfig(): Config {
if (!parsed || !Array.isArray(parsed.meshes)) { if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, 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) { } 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)}`,

View File

@@ -526,8 +526,11 @@ export class BrokerClient {
body: data, body: data,
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });
const body = await res.json() as { ok?: boolean; fileId?: string }; const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
return body.fileId ?? null; if (!res.ok || !body.fileId) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body.fileId;
} }
// --- Vectors --- // --- Vectors ---

View File

@@ -12,6 +12,7 @@ import { env } from "../env";
const clients = new Map<string, BrokerClient>(); const clients = new Map<string, BrokerClient>();
let configDisplayName: string | undefined; let configDisplayName: string | undefined;
let configGroups: Config["groups"] = [];
/** 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> {
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
clients.set(mesh.meshId, client); clients.set(mesh.meshId, client);
try { try {
await client.connect(); 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 { } catch {
// Connect failed → client is in "reconnecting" state, leave it // Connect failed → client is in "reconnecting" state, leave it
// wired so tool calls can surface the status. // 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. */ /** 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; configDisplayName = config.displayName;
configGroups = config.groups ?? [];
await Promise.allSettled(config.meshes.map(ensureClient)); await Promise.allSettled(config.meshes.map(ensureClient));
} }

View File

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

9036
bun.lock Normal file

File diff suppressed because it is too large Load Diff