10 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
10 changed files with 9303 additions and 177 deletions

View File

@@ -1302,11 +1302,28 @@ export async function drainForMember(
);
// Build group target matching: @all (broadcast alias) + @<groupname>
// for each group the peer belongs to.
// for each group the peer belongs to, expanded to all ancestor paths.
//
// Hierarchical routing (downward propagation):
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
// This lets leads send to a parent group and reach all sub-teams.
//
// Resolution happens at drain time (pull model) — no duplicates stored,
// no schema changes, fully backward-compatible.
const groupTargets = ["@all"];
if (memberGroups) {
const seen = new Set<string>();
for (const g of memberGroups) {
groupTargets.push(`@${g}`);
const parts = g.split("/");
// Add the group itself + every ancestor prefix.
for (let depth = parts.length; depth > 0; depth--) {
const ancestor = parts.slice(0, depth).join("/");
if (!seen.has(ancestor)) {
seen.add(ancestor);
groupTargets.push(`@${ancestor}`);
}
}
}
}
const groupTargetList = sql.raw(
@@ -1337,7 +1354,7 @@ export async function drainForMember(
AND delivered_at IS NULL
AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
)

View File

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

View File

@@ -123,7 +123,10 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey?: string,
): Promise<void> {
const conn = connections.get(presenceId);
if (!conn) return;
if (!conn) {
log.debug("maybePush: no connection for presence", { presence_id: presenceId });
return;
}
const status = await refreshStatusFromJsonl(
presenceId,
conn.cwd,
@@ -138,6 +141,13 @@ async function maybePushQueuedMessages(
excludeSenderSessionPubkey,
conn.groups.map((g) => g.name),
);
log.info("maybePush", {
presence_id: presenceId,
status,
session_pubkey: conn.sessionPubkey?.slice(0, 12),
exclude: excludeSenderSessionPubkey?.slice(0, 12),
drained: messages.length,
});
for (const m of messages) {
const push: WSPushMessage = {
type: "push",
@@ -435,16 +445,20 @@ function handleUploadPost(
: undefined,
);
// Insert DB row
// Insert DB row — normalise tags to a real JS Array (Drizzle PgArray
// mapper calls .map() on the value; non-Array iterables break it).
// Skip uploadedByMember FK — memberId from the client header is the
// mesh slug, not a mesh.member primary key.
const dbFileId = await uploadFile({
meshId,
name: fileName,
sizeBytes: body.length,
mimeType: (req.headers["content-type"] as string) || undefined,
minioKey,
tags,
tags: Array.isArray(tags) ? tags : [],
persistent,
uploadedByMember: memberId,
uploadedByName: memberId || undefined,
uploadedByMember: undefined,
targetSpec: targetSpec || undefined,
});
@@ -1658,6 +1672,9 @@ function handleConnection(ws: WebSocket): void {
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`);
const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
const peerConn = connections.get(presenceId);
// Find own display name: match sessionPubkey from the peer list
const selfPubkey = peerConn?.sessionPubkey ?? peerConn?.memberPubkey;
const selfPeer = peers.find(p => p.pubkey === selfPubkey);
sendToPeer(presenceId, {
type: "mesh_info_result",
mesh: conn.meshId,
@@ -1670,7 +1687,7 @@ function handleConnection(ws: WebSocket): void {
streams: streams.map(s => s.name),
tables: tables.map((t: any) => t.name),
collections: [],
yourName: peerConn?.groups?.[0]?.name ?? "unknown",
yourName: selfPeer?.displayName ?? "unknown",
yourGroups: peerConn?.groups ?? [],
});
log.info("ws mesh_info", { presence_id: presenceId });

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "0.5.7",
"version": "0.6.0",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [
"claude-code",
@@ -47,6 +47,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"ws": "8.20.0",
"zod": "4.1.13"

View File

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

View File

@@ -1,13 +1,15 @@
/**
* claudemesh-cli entry point.
*
* Uses citty to define commands and flags. --help is generated from
* the command definitions — the flag list here IS the documentation.
*
* Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport)
* - `claudemesh <subcommand>` → CLI subcommand
*
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
*/
import { defineCommand, runMain } from "citty";
import { startMcpServer } from "./mcp/server";
import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join";
@@ -21,96 +23,152 @@ import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome";
import { VERSION } from "./version";
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
Usage:
claudemesh <command> [args]
Commands:
install Register MCP + Stop/UserPromptSubmit status hooks
(add --no-hooks for bare MCP registration)
uninstall Remove MCP server + hooks
launch [opts] Launch Claude Code with real-time push messages
--name <name> Display name for this session
--mesh <slug> Select mesh (picker if >1, omitted)
--join <url> Join a mesh before launching
--quiet Skip the info banner
-- <args> Pass remaining args to claude
join <url> Join a mesh via https://claudemesh.com/join/... URL
list Show all joined meshes
leave <slug> Leave a joined mesh
status Health report: broker reachability per joined mesh
doctor Diagnostic checks (install, config, keypairs, PATH)
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
mcp Start MCP server (stdio) — invoked by Claude Code
--help, -h Show this help
--version, -v Show the CLI version
Environment:
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
CLAUDEMESH_DEBUG=1 Verbose logging
`;
const cmd = process.argv[2];
const args = process.argv.slice(3);
async function main(): Promise<void> {
switch (cmd) {
case "mcp":
await startMcpServer();
return;
case "install":
runInstall(args);
return;
case "uninstall":
runUninstall();
return;
case "hook":
await runHook(args);
return;
case "launch":
await runLaunch(args);
return;
case "join":
await runJoin(args);
return;
case "list":
runList();
return;
case "leave":
runLeave(args);
return;
case "status":
await runStatus();
return;
case "doctor":
await runDoctor();
return;
case "seed-test-mesh":
runSeedTestMesh(args);
return;
case "--version":
case "-v":
case "version":
console.log(VERSION);
return;
case "--help":
case "-h":
case "help":
console.log(HELP);
return;
case undefined:
runWelcome();
return;
default:
console.error(`Unknown command: ${cmd}`);
console.error("Run `claudemesh --help` for usage.");
process.exit(1);
}
}
main().catch((e) => {
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
const launch = defineCommand({
meta: {
name: "launch",
description: "Launch Claude Code connected to a mesh with real-time peer messaging",
},
args: {
name: {
type: "string",
description: "Display name for this session",
},
role: {
type: "string",
description: "Role tag (dev, lead, analyst — free-form)",
},
groups: {
type: "string",
description: 'Groups to join: "group:role,group2" — colon sets role. Hierarchy via slash: "eng/frontend:lead"',
},
mesh: {
type: "string",
description: "Select mesh by slug (interactive picker if omitted and >1 joined)",
},
join: {
type: "string",
description: "Join a mesh via invite URL before launching",
},
"message-mode": {
type: "string",
description: "push (default) | inbox | off — controls how peer messages are delivered",
},
"system-prompt": {
type: "string",
description: "Set Claude's system prompt for this session",
},
yes: {
type: "boolean",
alias: "y",
description: "Skip permission confirmation",
default: false,
},
quiet: {
type: "boolean",
description: "Skip banner and all interactive prompts",
default: false,
},
},
run({ args, rawArgs }) {
// Forward to the existing launch runner, preserving -- passthrough to claude.
return runLaunch(args, rawArgs);
},
});
const install = defineCommand({
meta: {
name: "install",
description: "Register MCP server + status hooks with Claude Code",
},
args: {
"no-hooks": {
type: "boolean",
description: "Register MCP server only, skip hooks",
default: false,
},
},
run({ rawArgs }) {
runInstall(rawArgs);
},
});
const join = defineCommand({
meta: {
name: "join",
description: "Join a mesh via invite URL",
},
args: {
url: {
type: "positional",
description: "Invite URL (https://claudemesh.com/join/...)",
required: true,
},
},
run({ args }) {
return runJoin([args.url]);
},
});
const leave = defineCommand({
meta: {
name: "leave",
description: "Leave a joined mesh",
},
args: {
slug: {
type: "positional",
description: "Mesh slug to leave",
required: true,
},
},
run({ args }) {
runLeave([args.slug]);
},
});
const main = defineCommand({
meta: {
name: "claudemesh",
version: VERSION,
description: "Peer mesh for Claude Code sessions",
},
subCommands: {
launch,
install,
uninstall: defineCommand({
meta: { name: "uninstall", description: "Remove MCP server and hooks" },
run() { runUninstall(); },
}),
join,
list: defineCommand({
meta: { name: "list", description: "Show joined meshes and identities" },
run() { runList(); },
}),
leave,
status: defineCommand({
meta: { name: "status", description: "Check broker reachability for each joined mesh" },
async run() { await runStatus(); },
}),
doctor: defineCommand({
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH" },
async run() { await runDoctor(); },
}),
mcp: defineCommand({
meta: { name: "mcp", description: "Start MCP server (stdio — invoked by Claude Code, not users)" },
async run() { await startMcpServer(); },
}),
"seed-test-mesh": defineCommand({
meta: { name: "seed-test-mesh", description: "Dev only: inject a mesh into config (skips invite flow)" },
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
}),
hook: defineCommand({
meta: { name: "hook", description: "Internal hook handler (invoked by Claude Code hooks)" },
async run({ rawArgs }) { await runHook(rawArgs); },
}),
},
run() {
runWelcome();
},
});
runMain(main);

View File

@@ -130,6 +130,7 @@ export async function startMcpServer(): Promise<void> {
const config = loadConfig();
const myName = config.displayName ?? "unnamed";
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none";
const messageMode = config.messageMode ?? "push";
@@ -141,7 +142,7 @@ export async function startMcpServer(): Promise<void> {
tools: {},
},
instructions: `## Identity
You are "${myName}" — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
@@ -859,6 +860,27 @@ 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

View File

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

View File

@@ -12,6 +12,7 @@ import { env } from "../env";
const clients = new Map<string, BrokerClient>();
let configDisplayName: string | undefined;
let configGroups: Config["groups"] = [];
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
clients.set(mesh.meshId, client);
try {
await client.connect();
// Auto-join groups declared at launch time (--groups flag or config).
for (const g of configGroups ?? []) {
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
}
} catch {
// Connect failed → client is in "reconnecting" state, leave it
// wired so tool calls can surface the status.
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
/** Start clients for every joined mesh. Called once on MCP server start. */
export async function startClients(config: Config): Promise<void> {
configDisplayName = config.displayName;
configGroups = config.groups ?? [];
await Promise.allSettled(config.meshes.map(ensureClient));
}

9036
bun.lock Normal file

File diff suppressed because it is too large Load Diff