feat: v0.2.0 — Groups (@group routing, roles, wizard)
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

Phase A of the claudemesh spec. Peers can now join named groups
with roles, and messages route to @group targets.

Broker:
- @group routing in fan-out (matches peer group membership)
- @all alias for broadcast
- join_group/leave_group WS messages + DB persistence
- list_peers returns group metadata
- drainForMember matches @group targetSpecs in SQL

CLI:
- join_group/leave_group MCP tools
- send_message supports @group targets
- list_peers shows group membership
- PeerInfo includes groups array
- Peer name cache for push notifications

Launch:
- --role flag (optional peer role)
- --groups flag (comma-separated, e.g. "frontend:lead,reviewers")
- Interactive wizard for role + groups when flags omitted
- Groups written to session config for broker hello

Spec: SPEC.md added with full v0.2 vision (groups, state, memory)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-06 13:06:16 +01:00
parent 663f800b4b
commit 02b1e5695f
17 changed files with 12109 additions and 22 deletions

View File

@@ -16,12 +16,14 @@ import { tmpdir, hostname } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh } 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;
quiet: boolean;
@@ -32,6 +34,8 @@ interface LaunchArgs {
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
role: null,
groups: null,
joinLink: null,
meshSlug: null,
quiet: false,
@@ -46,6 +50,14 @@ function parseArgs(argv: string[]): LaunchArgs {
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=")) {
@@ -95,6 +107,33 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
});
}
// --- Group string parser ---
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
function parseGroupsString(raw: string): GroupEntry[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((token) => {
const idx = token.indexOf(":");
if (idx === -1) return { name: token };
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
});
}
// --- Interactive role/groups prompts ---
function askLine(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// --- Permission confirmation ---
async function confirmPermissions(): Promise<void> {
@@ -132,14 +171,19 @@ async function confirmPermissions(): Promise<void> {
// --- Banner ---
function printBanner(name: string, meshSlug: string): void {
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[]): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const roleSuffix = role ? ` (${role})` : "";
const groupTags = groups.length
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags}`));
console.log(rule);
console.log("Peer messages arrive as <channel> reminders in real-time.");
console.log("Peers send text only — they cannot call tools or read files.");
@@ -210,11 +254,27 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
mesh = await pickMesh(config.meshes);
}
// 3. Session identity. The WS client auto-generates a per-session
// ephemeral keypair on connect (sent in hello as sessionPubkey).
// We just set the display name via env var.
// 3. Session identity + role/groups.
// The WS client auto-generates a per-session ephemeral keypair on
// connect (sent in hello as sessionPubkey). We set display name via env var.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
if (!args.quiet) {
if (role === null) {
const answer = await askLine(" Role (optional): ");
if (answer) role = answer;
}
if (parsedGroups.length === 0 && args.groups === null) {
const answer = await askLine(" Groups (comma-separated, optional): ");
if (answer) parsedGroups = parseGroupsString(answer);
}
if (role || parsedGroups.length) console.log("");
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
const tmpBase = tmpdir();
try {
@@ -232,6 +292,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
version: 1,
meshes: [mesh],
displayName,
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
};
writeFileSync(
join(tmpDir, "config.json"),
@@ -241,7 +302,7 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
// 5. Banner + permission confirmation.
if (!args.quiet) {
printBanner(displayName, mesh.slug);
printBanner(displayName, mesh.slug, role, parsedGroups);
// Auto-permissions confirmation — needed for autonomous peer messaging.
if (!args.skipPermConfirm) {
await confirmPermissions();

View File

@@ -62,8 +62,8 @@ async function resolveClient(to: string): Promise<{
target = rest;
}
}
// Pubkey, channel, or broadcast — pass through directly.
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target === "*") {
// Pubkey, channel, @group, or broadcast — pass through directly.
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target.startsWith("@") || target === "*") {
if (targetClients.length === 1) {
return { client: targetClients[0]!, targetSpec: target };
}
@@ -140,14 +140,16 @@ export async function startMcpServer(): Promise<void> {
IMPORTANT: When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Do not wait until your current task is finished. Pause what you are doing, reply to the message using send_message, then resume your work. Treat incoming peer messages like a coworker tapping you on the shoulder — answer right away, even if you're in the middle of something.
Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with to set to the from_name (display name) of the sender.
Read the from_id, from_name, mesh_slug, and priority attributes to understand context. Reply by calling send_message with to set to the from_name (display name) of the sender. The \`to\` field can be a peer name, pubkey, @group, or * for broadcast.
Available tools:
- list_peers: see joined meshes + their connection status
- send_message: send to a peer by display name, pubkey, #channel, or * broadcast (priority: now/next/low)
- send_message: send to a peer by display name, pubkey, @group, #channel, or * broadcast (priority: now/next/low)
- check_messages: drain buffered inbound messages (usually auto-pushed)
- set_summary: 1-2 sentence summary of what you're working on
- set_status: manually override your status (idle/working/dnd)
- join_group: join a @group with optional role
- leave_group: leave a @group
Message priority:
- "now": delivered immediately regardless of recipient status (use sparingly)
@@ -215,7 +217,8 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
} else {
const peerLines = peers.map((p) => {
const summary = p.summary ? ` — "${p.summary}"` : "";
return `- **${p.displayName}** [${p.status}] (${p.pubkey.slice(0, 12)}…)${summary}`;
const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : "";
return `- **${p.displayName}** [${p.status}]${groupsStr} (${p.pubkey.slice(0, 12)}…)${summary}`;
});
sections.push(`${header}\n${peerLines.join("\n")}`);
}
@@ -252,6 +255,20 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
return text(`Status set to ${s} across ${allClients().length} mesh(es).`);
}
case "join_group": {
const { name: groupName, role } = (args ?? {}) as { name?: string; role?: string };
if (!groupName) return text("join_group: `name` required", true);
for (const c of allClients()) await c.joinGroup(groupName, role);
return text(`Joined @${groupName}${role ? ` as ${role}` : ""}`);
}
case "leave_group": {
const { name: groupName } = (args ?? {}) as { name?: string };
if (!groupName) return text("leave_group: `name` required", true);
for (const c of allClients()) await c.leaveGroup(groupName);
return text(`Left @${groupName}`);
}
default:
return text(`Unknown tool: ${name}`, true);
}

View File

@@ -12,13 +12,13 @@ export const TOOLS: Tool[] = [
{
name: "send_message",
description:
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
inputSchema: {
type: "object",
properties: {
to: {
type: "string",
description: "Peer name, pubkey, or #channel",
description: "Peer name, pubkey, @group, or #channel",
},
message: { type: "string", description: "Message text" },
priority: {
@@ -78,4 +78,31 @@ export const TOOLS: Tool[] = [
required: ["status"],
},
},
{
name: "join_group",
description:
"Join a group with an optional role. Other peers see your group membership in list_peers.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Group name (without @)" },
role: {
type: "string",
description: "Your role in the group (e.g. lead, member, observer)",
},
},
required: ["name"],
},
},
{
name: "leave_group",
description: "Leave a group.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Group name (without @)" },
},
required: ["name"],
},
},
];

View File

@@ -28,10 +28,16 @@ export interface JoinedMesh {
joinedAt: string;
}
export interface GroupEntry {
name: string;
role?: string;
}
export interface Config {
version: 1;
meshes: JoinedMesh[];
displayName?: string; // per-session override, written by `claudemesh launch --name`
groups?: GroupEntry[];
}
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
@@ -47,7 +53,7 @@ export function loadConfig(): Config {
if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] };
}
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName };
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups };
} catch (e) {
throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -31,6 +31,7 @@ export interface PeerInfo {
displayName: string;
status: string;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: string;
}
@@ -312,6 +313,18 @@ export class BrokerClient {
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
}
/** Join a group with an optional role. */
async joinGroup(name: string, role?: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "join_group", name, role }));
}
/** Leave a group. */
async leaveGroup(name: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "leave_group", name }));
}
close(): void {
this.closed = true;
if (this.helloTimer) clearTimeout(this.helloTimer);