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

@@ -328,6 +328,7 @@ export interface ConnectParams {
displayName?: string;
pid: number;
cwd: string;
groups?: Array<{ name: string; role?: string }>;
}
/** Create a presence row for a new WS connection. */
@@ -347,6 +348,7 @@ export async function connectPresence(
status: "idle",
statusSource: "jsonl",
statusUpdatedAt: now,
groups: params.groups ?? [],
connectedAt: now,
lastPingAt: now,
})
@@ -384,6 +386,7 @@ export async function listPeersInMesh(
displayName: string;
status: string;
summary: string | null;
groups: Array<{ name: string; role?: string }>;
sessionId: string;
connectedAt: Date;
}>
@@ -396,6 +399,7 @@ export async function listPeersInMesh(
presenceDisplayName: presence.displayName,
status: presence.status,
summary: presence.summary,
groups: presence.groups,
sessionId: presence.sessionId,
connectedAt: presence.connectedAt,
})
@@ -414,6 +418,7 @@ export async function listPeersInMesh(
displayName: r.presenceDisplayName || r.memberDisplayName,
status: r.status,
summary: r.summary,
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
sessionId: r.sessionId,
connectedAt: r.connectedAt,
}));
@@ -430,6 +435,60 @@ export async function setSummary(
.where(eq(presence.id, presenceId));
}
// --- Group management ---
/**
* Join a group (upsert). If the peer is already in the group, update the role.
* Returns the updated groups array.
*/
export async function joinGroup(
presenceId: string,
name: string,
role?: string,
): Promise<Array<{ name: string; role?: string }>> {
const [row] = await db
.select({ groups: presence.groups })
.from(presence)
.where(eq(presence.id, presenceId));
if (!row) return [];
const groups = ((row.groups ?? []) as Array<{ name: string; role?: string }>).slice();
const idx = groups.findIndex((g) => g.name === name);
const entry: { name: string; role?: string } = { name };
if (role) entry.role = role;
if (idx >= 0) {
groups[idx] = entry;
} else {
groups.push(entry);
}
await db
.update(presence)
.set({ groups })
.where(eq(presence.id, presenceId));
return groups;
}
/**
* Leave a group. Returns the updated groups array.
*/
export async function leaveGroup(
presenceId: string,
name: string,
): Promise<Array<{ name: string; role?: string }>> {
const [row] = await db
.select({ groups: presence.groups })
.from(presence)
.where(eq(presence.id, presenceId));
if (!row) return [];
const groups = ((row.groups ?? []) as Array<{ name: string; role?: string }>).filter(
(g) => g.name !== name,
);
await db
.update(presence)
.set({ groups })
.where(eq(presence.id, presenceId));
return groups;
}
// --- Message queueing + delivery ---
export interface QueueParams {
@@ -493,6 +552,7 @@ export async function drainForMember(
status: PeerStatus,
sessionPubkey?: string,
excludeSenderSessionPubkey?: string,
memberGroups?: string[],
): Promise<
Array<{
id: string;
@@ -510,6 +570,18 @@ export async function drainForMember(
priorities.map((p) => `'${p}'`).join(","),
);
// Build group target matching: @all (broadcast alias) + @<groupname>
// for each group the peer belongs to.
const groupTargets = ["@all"];
if (memberGroups) {
for (const g of memberGroups) {
groupTargets.push(`@${g}`);
}
}
const groupTargetList = sql.raw(
groupTargets.map((t) => `'${t}'`).join(","),
);
// Atomic claim with SQL-side ordering. The CTE claims rows via
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
@@ -533,7 +605,7 @@ export async function drainForMember(
WHERE mesh_id = ${meshId}
AND delivered_at IS NULL
AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``})
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED