From d451fc296e782a7deefda69133a2f6e13621d1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:09:37 +0100 Subject: [PATCH] feat: hierarchical group routing + role wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/broker/src/broker.ts | 21 +++++++++++++++++++-- apps/cli/src/commands/launch.ts | 15 +++++++++++++++ apps/cli/src/mcp/server.ts | 3 ++- apps/cli/src/state/config.ts | 3 ++- apps/cli/src/ws/manager.ts | 6 ++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index 4e29fd4..621d579 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -1302,11 +1302,28 @@ export async function drainForMember( ); // Build group target matching: @all (broadcast alias) + @ - // 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(); 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( diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index da05301..f70b9bd 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -27,6 +27,7 @@ interface LaunchArgs { joinLink: string | null; meshSlug: string | null; messageMode: "push" | "inbox" | "off" | null; + systemPrompt: string | null; quiet: boolean; skipPermConfirm: boolean; claudeArgs: string[]; @@ -40,6 +41,7 @@ function parseArgs(argv: string[]): LaunchArgs { joinLink: null, meshSlug: null, messageMode: null, + systemPrompt: null, quiet: false, skipPermConfirm: false, claudeArgs: [], @@ -68,6 +70,16 @@ function parseArgs(argv: string[]): LaunchArgs { result.meshSlug = argv[++i]!; } else if (arg.startsWith("--mesh=")) { result.meshSlug = arg.slice("--mesh=".length); + } else if (arg === "--message-mode" && i + 1 < argv.length) { + const mode = argv[++i]! as "push" | "inbox" | "off"; + if (["push", "inbox", "off"].includes(mode)) result.messageMode = mode; + } else if (arg.startsWith("--message-mode=")) { + const mode = arg.slice("--message-mode=".length) as "push" | "inbox" | "off"; + if (["push", "inbox", "off"].includes(mode)) result.messageMode = mode; + } else if (arg === "--system-prompt" && i + 1 < argv.length) { + result.systemPrompt = argv[++i]!; + } else if (arg.startsWith("--system-prompt=")) { + result.systemPrompt = arg.slice("--system-prompt=".length); } else if (arg === "--inbox") { result.messageMode = "inbox"; } else if (arg === "--no-messages") { @@ -318,6 +330,7 @@ export async function runLaunch(extraArgs: string[]): Promise { version: 1, meshes: [mesh], displayName, + ...(role ? { role } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}), messageMode, }; @@ -351,6 +364,7 @@ export async function runLaunch(extraArgs: string[]): Promise { "--dangerously-load-development-channels", "server:claudemesh", "--dangerously-skip-permissions", + ...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []), ...filtered, ]; @@ -362,6 +376,7 @@ export async function runLaunch(extraArgs: string[]): Promise { ...process.env, CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_DISPLAY_NAME: displayName, + ...(role ? { CLAUDEMESH_ROLE: role } : {}), }, }); diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 65354de..6b3c4da 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -130,6 +130,7 @@ export async function startMcpServer(): Promise { 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 { 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 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. diff --git a/apps/cli/src/state/config.ts b/apps/cli/src/state/config.ts index 1c35511..7449fda 100644 --- a/apps/cli/src/state/config.ts +++ b/apps/cli/src/state/config.ts @@ -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)}`, diff --git a/apps/cli/src/ws/manager.ts b/apps/cli/src/ws/manager.ts index eb2ed11..00c231c 100644 --- a/apps/cli/src/ws/manager.ts +++ b/apps/cli/src/ws/manager.ts @@ -12,6 +12,7 @@ import { env } from "../env"; const clients = new Map(); 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 { @@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise { 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 { /** Start clients for every joined mesh. Called once on MCP server start. */ export async function startClients(config: Config): Promise { configDisplayName = config.displayName; + configGroups = config.groups ?? []; await Promise.allSettled(config.meshes.map(ensureClient)); }