feat: hierarchical group routing + role wiring
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:
@@ -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(
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user