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>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 12:09:37 +01:00
parent 3da5d71275
commit d451fc296e
5 changed files with 44 additions and 4 deletions

View File

@@ -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<void> {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
@@ -351,6 +364,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 +376,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

@@ -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.

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