Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b1e5695f |
379
SPEC.md
Normal file
379
SPEC.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# Claudemesh v0.2 — Specification
|
||||
|
||||
## What claudemesh is
|
||||
|
||||
A peer mesh where Claude Code sessions collaborate as equals. No orchestrator, no pipelines. Peers talk, share state, self-organize through groups, and coordinate via conventions — not hardcoded protocols.
|
||||
|
||||
## Five concepts
|
||||
|
||||
```
|
||||
Organization (billing, auth)
|
||||
└── Mesh (team workspace, persists)
|
||||
├── @group (routing label + role metadata, dynamic)
|
||||
│ └── Peer (session, ephemeral)
|
||||
├── State (live key-value, operational)
|
||||
└── Memory (persistent knowledge, institutional)
|
||||
```
|
||||
|
||||
Everything else is emergent from these five.
|
||||
|
||||
---
|
||||
|
||||
## 1. Peers
|
||||
|
||||
A peer is a Claude Code session connected to a mesh. Ephemeral — comes and goes. The mesh persists.
|
||||
|
||||
### Identity
|
||||
|
||||
Each `claudemesh launch` generates an ephemeral ed25519 keypair (session identity). The member identity (from `claudemesh join`) provides authentication. Session identity provides routing and encryption.
|
||||
|
||||
### Peer attributes
|
||||
|
||||
| Attribute | Source | Persists across sessions |
|
||||
|-----------|--------|--------------------------|
|
||||
| name | `--name` flag or wizard | No |
|
||||
| role | `--role` flag or wizard | No |
|
||||
| groups | `--groups` flag or wizard | No |
|
||||
| status | Hook-driven (idle/working/dnd) | No |
|
||||
| summary | `set_summary` tool call | No |
|
||||
| capabilities | Auto-detected from session | No |
|
||||
| sessionPubkey | Generated on connect | No |
|
||||
| memberId | From `claudemesh join` | Yes (in config) |
|
||||
|
||||
### Launch
|
||||
|
||||
```bash
|
||||
# Full args — zero prompts
|
||||
claudemesh launch --name Alice --role dev --groups frontend:lead,reviewers -y
|
||||
|
||||
# Partial — wizard fills the rest
|
||||
claudemesh launch --name Alice
|
||||
|
||||
# No args — full wizard
|
||||
claudemesh launch
|
||||
```
|
||||
|
||||
### Wizard
|
||||
|
||||
Interactive mode when args are missing. Each question is one line. Optional fields accept empty Enter. Only one mesh joined? Skip the mesh picker. Only relevant questions shown.
|
||||
|
||||
```
|
||||
Name: Alice
|
||||
Mesh: dev-team (2 peers online)
|
||||
Role (optional): dev
|
||||
Groups (optional): frontend:lead, reviewers
|
||||
|
||||
Autonomous mode
|
||||
Claude will send and receive peer messages without
|
||||
asking you first. Peers exchange text only.
|
||||
|
||||
Continue? [Y/n]
|
||||
```
|
||||
|
||||
`-y` skips the confirmation. `--quiet` skips the banner. Any arg provided skips its question.
|
||||
|
||||
---
|
||||
|
||||
## 2. Groups
|
||||
|
||||
A group is a named subset of peers. Not a channel — no message history, no persistence. Just a routing label stored on the presence row.
|
||||
|
||||
### Syntax
|
||||
|
||||
`@groupname` in message routing. Declared at launch via `--groups`.
|
||||
|
||||
```bash
|
||||
claudemesh launch --name Alice --groups "frontend:lead,reviewers:member,all"
|
||||
```
|
||||
|
||||
Format: `groupname` or `groupname:role`. Role is a free-form string stored as metadata. The broker does not interpret roles — Claude does.
|
||||
|
||||
### Routing
|
||||
|
||||
```
|
||||
send_message(to: "@frontend", message: "auth is broken")
|
||||
```
|
||||
|
||||
Broker delivers to all peers whose groups include `frontend`. Sender excluded.
|
||||
|
||||
### Built-in groups
|
||||
|
||||
- `@all` — every peer in the mesh. Alias for `*` broadcast.
|
||||
|
||||
### Group metadata in list_peers
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Alice",
|
||||
"status": "working",
|
||||
"groups": [
|
||||
{ "name": "frontend", "role": "lead" },
|
||||
{ "name": "reviewers", "role": "member" }
|
||||
],
|
||||
"summary": "Implementing auth UI"
|
||||
}
|
||||
```
|
||||
|
||||
Peers read this metadata and coordinate based on their system prompts. A "lead" gathers input before responding. A "member" sends their take to the lead. An "observer" stays silent unless asked. The broker doesn't enforce these — Claude does.
|
||||
|
||||
### Dynamic group management
|
||||
|
||||
```
|
||||
join_group(name: "frontend", role: "member")
|
||||
leave_group(name: "frontend")
|
||||
```
|
||||
|
||||
MCP tools. Update the presence row. Other peers see the change on next `list_peers`.
|
||||
|
||||
---
|
||||
|
||||
## 3. State
|
||||
|
||||
A shared key-value store scoped to a mesh. Any peer can read or write. Changes push to subscribed peers.
|
||||
|
||||
### Why
|
||||
|
||||
Peers shouldn't need to message each other to agree on facts. "Is the deploy frozen?" should be a state read, not a conversation.
|
||||
|
||||
### Tools
|
||||
|
||||
```
|
||||
set_state(key: "deploy_frozen", value: true)
|
||||
get_state(key: "deploy_frozen") → true
|
||||
list_state() → [{ key, value, updatedBy, updatedAt }]
|
||||
watch_state(key: "deploy_frozen") → push notification on change
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Broker-side. PostgreSQL table in the mesh schema:
|
||||
|
||||
```sql
|
||||
mesh.state (
|
||||
id text PK,
|
||||
mesh_id text FK,
|
||||
key text NOT NULL,
|
||||
value jsonb NOT NULL,
|
||||
updated_by text FK (presence.id),
|
||||
updated_at timestamp,
|
||||
UNIQUE(mesh_id, key)
|
||||
)
|
||||
```
|
||||
|
||||
### Push on change
|
||||
|
||||
When a peer calls `set_state`, the broker pushes a notification to all connected peers in the mesh:
|
||||
|
||||
```json
|
||||
{ "type": "state_change", "key": "deploy_frozen", "value": true, "updatedBy": "Alice" }
|
||||
```
|
||||
|
||||
The CLI MCP server translates this to a `notifications/claude/channel` push, same as messages.
|
||||
|
||||
### Scope
|
||||
|
||||
State is mesh-scoped and ephemeral (lives as long as the mesh). Not designed for persistence across mesh restarts — use Memory for that.
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory
|
||||
|
||||
Persistent shared knowledge that survives across sessions. The mesh's institutional memory.
|
||||
|
||||
### Why
|
||||
|
||||
When a new peer joins the mesh, it has zero context. Memory provides the team's accumulated knowledge: decisions made, bugs found, preferences learned.
|
||||
|
||||
### Tools
|
||||
|
||||
```
|
||||
remember(content: "Payments API rate-limits at 100 req/s after the March incident")
|
||||
recall(query: "payments API") → [{ content, rememberedBy, rememberedAt }]
|
||||
forget(id: "mem_abc123")
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Broker-side. PostgreSQL table:
|
||||
|
||||
```sql
|
||||
mesh.memory (
|
||||
id text PK,
|
||||
mesh_id text FK,
|
||||
content text NOT NULL,
|
||||
tags text[],
|
||||
remembered_by text FK (member.id),
|
||||
remembered_at timestamp,
|
||||
forgotten_at timestamp
|
||||
)
|
||||
```
|
||||
|
||||
### Recall
|
||||
|
||||
Full-text search (PostgreSQL `tsvector`). Returns relevant memories ranked by relevance. Peers can call `recall` at session start to load context.
|
||||
|
||||
### Memory vs State
|
||||
|
||||
| | State | Memory |
|
||||
|---|---|---|
|
||||
| Lifetime | Session (ephemeral) | Permanent (until forgotten) |
|
||||
| Purpose | Operational coordination | Institutional knowledge |
|
||||
| Example | `deploy_frozen: true` | "Never deploy on Fridays — oncall learned this the hard way" |
|
||||
| Access | get/set/watch | remember/recall/forget |
|
||||
|
||||
---
|
||||
|
||||
## 5. MCP Tools (complete surface)
|
||||
|
||||
### Messaging
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `send_message(to, message, priority?)` | Send to peer name, pubkey, @group, or * |
|
||||
| `check_messages()` | Drain buffered messages (fallback for non-push) |
|
||||
|
||||
### Presence
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_peers(group?)` | List connected peers, optionally filtered by group |
|
||||
| `set_summary(summary)` | Set session summary visible to peers |
|
||||
| `set_status(status)` | Override status: idle, working, dnd |
|
||||
|
||||
### Groups
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `join_group(name, role?)` | Join a group with optional role |
|
||||
| `leave_group(name)` | Leave a group |
|
||||
|
||||
### State
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_state(key)` | Read a value |
|
||||
| `set_state(key, value)` | Write a value (pushes to all peers) |
|
||||
| `list_state()` | List all state keys and values |
|
||||
|
||||
### Memory
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `remember(content, tags?)` | Store persistent knowledge |
|
||||
| `recall(query)` | Search memories by relevance |
|
||||
| `forget(id)` | Soft-delete a memory |
|
||||
|
||||
---
|
||||
|
||||
## 6. WS Protocol additions
|
||||
|
||||
### Client → Broker
|
||||
|
||||
| Type | Fields | Description |
|
||||
|------|--------|-------------|
|
||||
| `join_group` | name, role? | Add group to this presence |
|
||||
| `leave_group` | name | Remove group from this presence |
|
||||
| `set_state` | key, value | Write shared state |
|
||||
| `get_state` | key | Read shared state |
|
||||
| `list_state` | — | List all state entries |
|
||||
| `remember` | content, tags? | Store a memory |
|
||||
| `recall` | query | Search memories |
|
||||
| `forget` | memoryId | Soft-delete a memory |
|
||||
|
||||
### Broker → Client
|
||||
|
||||
| Type | Fields | Description |
|
||||
|------|--------|-------------|
|
||||
| `state_change` | key, value, updatedBy | Pushed on any set_state |
|
||||
| `state_result` | key, value | Response to get_state |
|
||||
| `state_list` | entries[] | Response to list_state |
|
||||
| `memory_stored` | id | Ack for remember |
|
||||
| `memory_results` | memories[] | Response to recall |
|
||||
|
||||
---
|
||||
|
||||
## 7. DB schema additions
|
||||
|
||||
### mesh.presence (modify existing)
|
||||
|
||||
```sql
|
||||
ADD COLUMN groups jsonb DEFAULT '[]';
|
||||
-- Format: [{"name": "frontend", "role": "lead"}, ...]
|
||||
```
|
||||
|
||||
### mesh.state (new table)
|
||||
|
||||
```sql
|
||||
CREATE TABLE mesh.state (
|
||||
id text PRIMARY KEY,
|
||||
mesh_id text REFERENCES mesh.mesh(id) ON DELETE CASCADE,
|
||||
key text NOT NULL,
|
||||
value jsonb NOT NULL,
|
||||
updated_by_presence text REFERENCES mesh.presence(id),
|
||||
updated_by_name text,
|
||||
updated_at timestamp DEFAULT NOW(),
|
||||
UNIQUE(mesh_id, key)
|
||||
);
|
||||
```
|
||||
|
||||
### mesh.memory (new table)
|
||||
|
||||
```sql
|
||||
CREATE TABLE mesh.memory (
|
||||
id text PRIMARY KEY,
|
||||
mesh_id text REFERENCES mesh.mesh(id) ON DELETE CASCADE,
|
||||
content text NOT NULL,
|
||||
tags text[] DEFAULT '{}',
|
||||
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
|
||||
remembered_by text REFERENCES mesh.member(id),
|
||||
remembered_by_name text,
|
||||
remembered_at timestamp DEFAULT NOW(),
|
||||
forgotten_at timestamp
|
||||
);
|
||||
CREATE INDEX memory_search_idx ON mesh.memory USING gin(search_vector);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation phases
|
||||
|
||||
### Phase A: Groups (v0.2.0)
|
||||
|
||||
- `--groups` flag in launch + wizard question
|
||||
- `groups` jsonb column on presence
|
||||
- `join_group` / `leave_group` WS messages + MCP tools
|
||||
- `@group` routing in broker's handleSend
|
||||
- `list_peers` returns group metadata
|
||||
- Group sender exclusion (don't echo back to sender)
|
||||
|
||||
### Phase B: State (v0.3.0)
|
||||
|
||||
- `mesh.state` table + migrations
|
||||
- `set_state` / `get_state` / `list_state` WS messages + MCP tools
|
||||
- State change push notifications to all mesh peers
|
||||
- State displayed in dashboard
|
||||
|
||||
### Phase C: Memory (v0.4.0)
|
||||
|
||||
- `mesh.memory` table with tsvector + gin index
|
||||
- `remember` / `recall` / `forget` WS messages + MCP tools
|
||||
- Full-text search via PostgreSQL
|
||||
- Memory accessible from dashboard
|
||||
|
||||
### Phase D: Dashboard (v0.5.0)
|
||||
|
||||
- Live peer list with groups, roles, status
|
||||
- State viewer/editor
|
||||
- Memory browser
|
||||
- Message log (opt-in, plaintext only)
|
||||
|
||||
---
|
||||
|
||||
## 9. What the broker does NOT do
|
||||
|
||||
- **Interpret roles.** "lead", "member", "observer" are strings. Claude reads them and decides how to behave.
|
||||
- **Enforce coordination protocols.** Voting, consensus, delegation — all emergent from system prompts + group metadata.
|
||||
- **Store message history.** Messages are delivered and discarded. The queue holds undelivered messages only.
|
||||
- **Run agents.** The broker routes messages and stores state. Claude does everything else.
|
||||
|
||||
The broker is a dumb pipe with a bulletin board. The intelligence lives at the edges.
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
findMemberByPubkey,
|
||||
handleHookSetStatus,
|
||||
heartbeat,
|
||||
joinGroup,
|
||||
joinMesh,
|
||||
leaveGroup,
|
||||
listPeersInMesh,
|
||||
queueMessage,
|
||||
refreshQueueDepth,
|
||||
@@ -58,6 +60,7 @@ interface PeerConn {
|
||||
memberPubkey: string;
|
||||
sessionPubkey: string | null;
|
||||
cwd: string;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
}
|
||||
|
||||
const connections = new Map<string, PeerConn>();
|
||||
@@ -99,6 +102,7 @@ async function maybePushQueuedMessages(
|
||||
status,
|
||||
conn.sessionPubkey ?? undefined,
|
||||
excludeSenderSessionPubkey,
|
||||
conn.groups.map((g) => g.name),
|
||||
);
|
||||
for (const m of messages) {
|
||||
const push: WSPushMessage = {
|
||||
@@ -403,6 +407,7 @@ async function handleHello(
|
||||
ws.close(1008, "unauthorized");
|
||||
return null;
|
||||
}
|
||||
const initialGroups = hello.groups ?? [];
|
||||
const presenceId = await connectPresence({
|
||||
memberId: member.id,
|
||||
sessionId: hello.sessionId,
|
||||
@@ -410,6 +415,7 @@ async function handleHello(
|
||||
displayName: hello.displayName,
|
||||
pid: hello.pid,
|
||||
cwd: hello.cwd,
|
||||
groups: initialGroups,
|
||||
});
|
||||
connections.set(presenceId, {
|
||||
ws,
|
||||
@@ -418,6 +424,7 @@ async function handleHello(
|
||||
memberPubkey: hello.pubkey,
|
||||
sessionPubkey: hello.sessionPubkey ?? null,
|
||||
cwd: hello.cwd,
|
||||
groups: initialGroups,
|
||||
});
|
||||
incMeshCount(hello.meshId);
|
||||
const effectiveDisplayName = hello.displayName || member.displayName;
|
||||
@@ -463,13 +470,31 @@ async function handleSend(
|
||||
}
|
||||
|
||||
// Fan-out over connected peers in the same mesh — skip sender.
|
||||
// Resolve @group routing: "@all" is alias for "*", "@<name>" matches
|
||||
// peers whose in-memory groups array contains that group name.
|
||||
const isGroupTarget = msg.targetSpec.startsWith("@");
|
||||
const isBroadcast =
|
||||
msg.targetSpec === "*" ||
|
||||
(isGroupTarget && msg.targetSpec === "@all");
|
||||
const groupName = isGroupTarget && !isBroadcast
|
||||
? msg.targetSpec.slice(1)
|
||||
: null;
|
||||
|
||||
for (const [pid, peer] of connections) {
|
||||
if (pid === senderPresenceId) continue;
|
||||
if (peer.meshId !== conn.meshId) continue;
|
||||
if (msg.targetSpec !== "*"
|
||||
&& peer.memberPubkey !== msg.targetSpec
|
||||
&& peer.sessionPubkey !== msg.targetSpec)
|
||||
continue;
|
||||
|
||||
if (isBroadcast) {
|
||||
// broadcast — deliver to everyone
|
||||
} else if (groupName) {
|
||||
// group routing — deliver only if peer is in the group
|
||||
if (!peer.groups.some((g) => g.name === groupName)) continue;
|
||||
} else {
|
||||
// direct routing — match by pubkey
|
||||
if (peer.memberPubkey !== msg.targetSpec
|
||||
&& peer.sessionPubkey !== msg.targetSpec)
|
||||
continue;
|
||||
}
|
||||
void maybePushQueuedMessages(pid, conn.sessionPubkey ?? undefined);
|
||||
}
|
||||
}
|
||||
@@ -525,6 +550,7 @@ function handleConnection(ws: WebSocket): void {
|
||||
displayName: p.displayName,
|
||||
status: p.status as "idle" | "working" | "dnd",
|
||||
summary: p.summary,
|
||||
groups: p.groups,
|
||||
sessionId: p.sessionId,
|
||||
connectedAt: p.connectedAt.toISOString(),
|
||||
})),
|
||||
@@ -546,6 +572,27 @@ function handleConnection(ws: WebSocket): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "join_group": {
|
||||
const jg = msg as Extract<WSClientMessage, { type: "join_group" }>;
|
||||
const updatedGroups = await joinGroup(presenceId, jg.name, jg.role);
|
||||
conn.groups = updatedGroups;
|
||||
log.info("ws join_group", {
|
||||
presence_id: presenceId,
|
||||
group: jg.name,
|
||||
role: jg.role,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "leave_group": {
|
||||
const lg = msg as Extract<WSClientMessage, { type: "leave_group" }>;
|
||||
const updatedGroups = await leaveGroup(presenceId, lg.name);
|
||||
conn.groups = updatedGroups;
|
||||
log.info("ws leave_group", {
|
||||
presence_id: presenceId,
|
||||
group: lg.name,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface WSHelloMessage {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
/** Initial groups to join on connect. */
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||
timestamp: number;
|
||||
/** ed25519 signature (hex) over the canonical hello bytes:
|
||||
@@ -103,6 +105,19 @@ export interface WSSetSummaryMessage {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** Client → broker: join a group with optional role. */
|
||||
export interface WSJoinGroupMessage {
|
||||
type: "join_group";
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: leave a group. */
|
||||
export interface WSLeaveGroupMessage {
|
||||
type: "leave_group";
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for a send. */
|
||||
export interface WSAckMessage {
|
||||
type: "ack";
|
||||
@@ -126,6 +141,7 @@ export interface WSPeersListMessage {
|
||||
displayName: string;
|
||||
status: PeerStatus;
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
}>;
|
||||
@@ -144,7 +160,9 @@ export type WSClientMessage =
|
||||
| WSSendMessage
|
||||
| WSSetStatusMessage
|
||||
| WSListPeersMessage
|
||||
| WSSetSummaryMessage;
|
||||
| WSSetSummaryMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage;
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.2.0",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
@@ -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);
|
||||
|
||||
1
packages/db/migrations/0007_add-presence-groups.sql
Normal file
1
packages/db/migrations/0007_add-presence-groups.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "mesh"."presence" ADD COLUMN "groups" jsonb DEFAULT '[]'::jsonb;
|
||||
2845
packages/db/migrations/meta/0004_snapshot.json
Normal file
2845
packages/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2851
packages/db/migrations/meta/0005_snapshot.json
Normal file
2851
packages/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2857
packages/db/migrations/meta/0006_snapshot.json
Normal file
2857
packages/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2864
packages/db/migrations/meta/0007_snapshot.json
Normal file
2864
packages/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,34 @@
|
||||
"when": 1775463897329,
|
||||
"tag": "0003_add-presence-summary",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1775468683383,
|
||||
"tag": "0004_add-presence-display-name",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1775470435032,
|
||||
"tag": "0005_add-presence-session-pubkey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1775470979207,
|
||||
"tag": "0006_add-sender-session-pubkey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1775476994511,
|
||||
"tag": "0007_add-presence-groups",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -200,6 +200,7 @@ export const presence = meshSchema.table("presence", {
|
||||
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
|
||||
statusUpdatedAt: timestamp().defaultNow().notNull(),
|
||||
summary: text(),
|
||||
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
|
||||
connectedAt: timestamp().defaultNow().notNull(),
|
||||
lastPingAt: timestamp().defaultNow().notNull(),
|
||||
disconnectedAt: timestamp(),
|
||||
|
||||
Reference in New Issue
Block a user