feat: v0.2.0 — Groups (@group routing, roles, wizard)
Some checks failed
Some checks failed
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user