Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b1e5695f | ||
|
|
663f800b4b | ||
|
|
2557235c68 | ||
|
|
a987e9e27b | ||
|
|
ff86db615f | ||
|
|
4aa61b40e2 | ||
|
|
4afe365c00 | ||
|
|
92bb276a3e | ||
|
|
af8f8ed1f9 | ||
|
|
c8682dd700 |
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.
|
||||||
@@ -265,6 +265,23 @@ export async function refreshQueueDepth(): Promise<void> {
|
|||||||
metrics.queueDepth.set(Number(row?.n ?? 0));
|
metrics.queueDepth.set(Number(row?.n ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep stale presences: mark as disconnected if last_ping_at is older
|
||||||
|
* than 90s (3 missed pings at the 30s interval = dead session).
|
||||||
|
*/
|
||||||
|
export async function sweepStalePresences(): Promise<void> {
|
||||||
|
const cutoff = new Date(Date.now() - 90_000); // 3 missed pings
|
||||||
|
await db
|
||||||
|
.update(presence)
|
||||||
|
.set({ disconnectedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(presence.disconnectedAt),
|
||||||
|
lt(presence.lastPingAt, cutoff),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Sweep expired pending_status entries. */
|
/** Sweep expired pending_status entries. */
|
||||||
export async function sweepPendingStatuses(): Promise<void> {
|
export async function sweepPendingStatuses(): Promise<void> {
|
||||||
const cutoff = new Date(Date.now() - PENDING_TTL_MS);
|
const cutoff = new Date(Date.now() - PENDING_TTL_MS);
|
||||||
@@ -307,9 +324,11 @@ export async function refreshStatusFromJsonl(
|
|||||||
export interface ConnectParams {
|
export interface ConnectParams {
|
||||||
memberId: string;
|
memberId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
sessionPubkey?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
groups?: Array<{ name: string; role?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a presence row for a new WS connection. */
|
/** Create a presence row for a new WS connection. */
|
||||||
@@ -322,12 +341,14 @@ export async function connectPresence(
|
|||||||
.values({
|
.values({
|
||||||
memberId: params.memberId,
|
memberId: params.memberId,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
sessionPubkey: params.sessionPubkey ?? null,
|
||||||
displayName: params.displayName ?? null,
|
displayName: params.displayName ?? null,
|
||||||
pid: params.pid,
|
pid: params.pid,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
statusSource: "jsonl",
|
statusSource: "jsonl",
|
||||||
statusUpdatedAt: now,
|
statusUpdatedAt: now,
|
||||||
|
groups: params.groups ?? [],
|
||||||
connectedAt: now,
|
connectedAt: now,
|
||||||
lastPingAt: now,
|
lastPingAt: now,
|
||||||
})
|
})
|
||||||
@@ -365,17 +386,20 @@ export async function listPeersInMesh(
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
status: string;
|
status: string;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
connectedAt: Date;
|
connectedAt: Date;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
pubkey: memberTable.peerPubkey,
|
memberPubkey: memberTable.peerPubkey,
|
||||||
|
sessionPubkey: presence.sessionPubkey,
|
||||||
memberDisplayName: memberTable.displayName,
|
memberDisplayName: memberTable.displayName,
|
||||||
presenceDisplayName: presence.displayName,
|
presenceDisplayName: presence.displayName,
|
||||||
status: presence.status,
|
status: presence.status,
|
||||||
summary: presence.summary,
|
summary: presence.summary,
|
||||||
|
groups: presence.groups,
|
||||||
sessionId: presence.sessionId,
|
sessionId: presence.sessionId,
|
||||||
connectedAt: presence.connectedAt,
|
connectedAt: presence.connectedAt,
|
||||||
})
|
})
|
||||||
@@ -388,12 +412,13 @@ export async function listPeersInMesh(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(presence.connectedAt));
|
.orderBy(asc(presence.connectedAt));
|
||||||
// Prefer per-session displayName over member-level displayName.
|
// Prefer session pubkey for routing, session displayName for display.
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
pubkey: r.pubkey,
|
pubkey: r.sessionPubkey || r.memberPubkey,
|
||||||
displayName: r.presenceDisplayName || r.memberDisplayName,
|
displayName: r.presenceDisplayName || r.memberDisplayName,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
summary: r.summary,
|
summary: r.summary,
|
||||||
|
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
|
||||||
sessionId: r.sessionId,
|
sessionId: r.sessionId,
|
||||||
connectedAt: r.connectedAt,
|
connectedAt: r.connectedAt,
|
||||||
}));
|
}));
|
||||||
@@ -410,11 +435,66 @@ export async function setSummary(
|
|||||||
.where(eq(presence.id, presenceId));
|
.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 ---
|
// --- Message queueing + delivery ---
|
||||||
|
|
||||||
export interface QueueParams {
|
export interface QueueParams {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
senderMemberId: string;
|
senderMemberId: string;
|
||||||
|
senderSessionPubkey?: string;
|
||||||
targetSpec: string;
|
targetSpec: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
@@ -429,6 +509,7 @@ export async function queueMessage(params: QueueParams): Promise<string> {
|
|||||||
.values({
|
.values({
|
||||||
meshId: params.meshId,
|
meshId: params.meshId,
|
||||||
senderMemberId: params.senderMemberId,
|
senderMemberId: params.senderMemberId,
|
||||||
|
senderSessionPubkey: params.senderSessionPubkey ?? null,
|
||||||
targetSpec: params.targetSpec,
|
targetSpec: params.targetSpec,
|
||||||
priority: params.priority,
|
priority: params.priority,
|
||||||
nonce: params.nonce,
|
nonce: params.nonce,
|
||||||
@@ -469,6 +550,9 @@ export async function drainForMember(
|
|||||||
_memberId: string,
|
_memberId: string,
|
||||||
memberPubkey: string,
|
memberPubkey: string,
|
||||||
status: PeerStatus,
|
status: PeerStatus,
|
||||||
|
sessionPubkey?: string,
|
||||||
|
excludeSenderSessionPubkey?: string,
|
||||||
|
memberGroups?: string[],
|
||||||
): Promise<
|
): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -486,6 +570,18 @@ export async function drainForMember(
|
|||||||
priorities.map((p) => `'${p}'`).join(","),
|
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
|
// Atomic claim with SQL-side ordering. The CTE claims rows via
|
||||||
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
|
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
|
||||||
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
|
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
|
||||||
@@ -509,14 +605,15 @@ export async function drainForMember(
|
|||||||
WHERE mesh_id = ${meshId}
|
WHERE mesh_id = ${meshId}
|
||||||
AND delivered_at IS NULL
|
AND delivered_at IS NULL
|
||||||
AND priority::text IN (${priorityList})
|
AND priority::text IN (${priorityList})
|
||||||
AND (target_spec = ${memberPubkey} OR target_spec = '*')
|
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
|
ORDER BY created_at ASC, id ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
)
|
)
|
||||||
AND m.id = mq.sender_member_id
|
AND m.id = mq.sender_member_id
|
||||||
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
||||||
mq.created_at, mq.sender_member_id,
|
mq.created_at, mq.sender_member_id,
|
||||||
m.peer_pubkey AS sender_pubkey
|
COALESCE(mq.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey
|
||||||
)
|
)
|
||||||
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
|
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
|
||||||
`);
|
`);
|
||||||
@@ -547,6 +644,7 @@ export async function drainForMember(
|
|||||||
|
|
||||||
let ttlTimer: ReturnType<typeof setInterval> | null = null;
|
let ttlTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let pendingTimer: ReturnType<typeof setInterval> | null = null;
|
let pendingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let staleTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
/** Start background sweepers. Idempotent. */
|
/** Start background sweepers. Idempotent. */
|
||||||
export function startSweepers(): void {
|
export function startSweepers(): void {
|
||||||
@@ -559,14 +657,21 @@ export function startSweepers(): void {
|
|||||||
console.error("[broker] pending sweep:", e),
|
console.error("[broker] pending sweep:", e),
|
||||||
);
|
);
|
||||||
}, PENDING_SWEEP_INTERVAL_MS);
|
}, PENDING_SWEEP_INTERVAL_MS);
|
||||||
|
staleTimer = setInterval(() => {
|
||||||
|
sweepStalePresences().catch((e) =>
|
||||||
|
console.error("[broker] stale presence sweep:", e),
|
||||||
|
);
|
||||||
|
}, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop background sweepers and mark all active presences disconnected. */
|
/** Stop background sweepers and mark all active presences disconnected. */
|
||||||
export async function stopSweepers(): Promise<void> {
|
export async function stopSweepers(): Promise<void> {
|
||||||
if (ttlTimer) clearInterval(ttlTimer);
|
if (ttlTimer) clearInterval(ttlTimer);
|
||||||
if (pendingTimer) clearInterval(pendingTimer);
|
if (pendingTimer) clearInterval(pendingTimer);
|
||||||
|
if (staleTimer) clearInterval(staleTimer);
|
||||||
ttlTimer = null;
|
ttlTimer = null;
|
||||||
pendingTimer = null;
|
pendingTimer = null;
|
||||||
|
staleTimer = null;
|
||||||
await db
|
await db
|
||||||
.update(presence)
|
.update(presence)
|
||||||
.set({ disconnectedAt: new Date() })
|
.set({ disconnectedAt: new Date() })
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
findMemberByPubkey,
|
findMemberByPubkey,
|
||||||
handleHookSetStatus,
|
handleHookSetStatus,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
|
joinGroup,
|
||||||
joinMesh,
|
joinMesh,
|
||||||
|
leaveGroup,
|
||||||
listPeersInMesh,
|
listPeersInMesh,
|
||||||
queueMessage,
|
queueMessage,
|
||||||
refreshQueueDepth,
|
refreshQueueDepth,
|
||||||
@@ -56,7 +58,9 @@ interface PeerConn {
|
|||||||
meshId: string;
|
meshId: string;
|
||||||
memberId: string;
|
memberId: string;
|
||||||
memberPubkey: string;
|
memberPubkey: string;
|
||||||
|
sessionPubkey: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = new Map<string, PeerConn>();
|
const connections = new Map<string, PeerConn>();
|
||||||
@@ -80,7 +84,10 @@ function sendToPeer(presenceId: string, msg: WSServerMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybePushQueuedMessages(presenceId: string): Promise<void> {
|
async function maybePushQueuedMessages(
|
||||||
|
presenceId: string,
|
||||||
|
excludeSenderSessionPubkey?: string,
|
||||||
|
): Promise<void> {
|
||||||
const conn = connections.get(presenceId);
|
const conn = connections.get(presenceId);
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
const status = await refreshStatusFromJsonl(
|
const status = await refreshStatusFromJsonl(
|
||||||
@@ -93,6 +100,9 @@ async function maybePushQueuedMessages(presenceId: string): Promise<void> {
|
|||||||
conn.memberId,
|
conn.memberId,
|
||||||
conn.memberPubkey,
|
conn.memberPubkey,
|
||||||
status,
|
status,
|
||||||
|
conn.sessionPubkey ?? undefined,
|
||||||
|
excludeSenderSessionPubkey,
|
||||||
|
conn.groups.map((g) => g.name),
|
||||||
);
|
);
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const push: WSPushMessage = {
|
const push: WSPushMessage = {
|
||||||
@@ -397,19 +407,24 @@ async function handleHello(
|
|||||||
ws.close(1008, "unauthorized");
|
ws.close(1008, "unauthorized");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const initialGroups = hello.groups ?? [];
|
||||||
const presenceId = await connectPresence({
|
const presenceId = await connectPresence({
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
sessionId: hello.sessionId,
|
sessionId: hello.sessionId,
|
||||||
|
sessionPubkey: hello.sessionPubkey,
|
||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
pid: hello.pid,
|
pid: hello.pid,
|
||||||
cwd: hello.cwd,
|
cwd: hello.cwd,
|
||||||
|
groups: initialGroups,
|
||||||
});
|
});
|
||||||
connections.set(presenceId, {
|
connections.set(presenceId, {
|
||||||
ws,
|
ws,
|
||||||
meshId: hello.meshId,
|
meshId: hello.meshId,
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
memberPubkey: hello.pubkey,
|
memberPubkey: hello.pubkey,
|
||||||
|
sessionPubkey: hello.sessionPubkey ?? null,
|
||||||
cwd: hello.cwd,
|
cwd: hello.cwd,
|
||||||
|
groups: initialGroups,
|
||||||
});
|
});
|
||||||
incMeshCount(hello.meshId);
|
incMeshCount(hello.meshId);
|
||||||
const effectiveDisplayName = hello.displayName || member.displayName;
|
const effectiveDisplayName = hello.displayName || member.displayName;
|
||||||
@@ -434,6 +449,7 @@ async function handleSend(
|
|||||||
const messageId = await queueMessage({
|
const messageId = await queueMessage({
|
||||||
meshId: conn.meshId,
|
meshId: conn.meshId,
|
||||||
senderMemberId: conn.memberId,
|
senderMemberId: conn.memberId,
|
||||||
|
senderSessionPubkey: conn.sessionPubkey ?? undefined,
|
||||||
targetSpec: msg.targetSpec,
|
targetSpec: msg.targetSpec,
|
||||||
priority: msg.priority,
|
priority: msg.priority,
|
||||||
nonce: msg.nonce,
|
nonce: msg.nonce,
|
||||||
@@ -447,12 +463,39 @@ async function handleSend(
|
|||||||
};
|
};
|
||||||
conn.ws.send(JSON.stringify(ack));
|
conn.ws.send(JSON.stringify(ack));
|
||||||
|
|
||||||
// Fan-out over connected peers in the same mesh.
|
// Find sender's presenceId to exclude from fan-out.
|
||||||
|
let senderPresenceId: string | undefined;
|
||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
|
if (peer.ws === conn.ws) { senderPresenceId = pid; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (peer.meshId !== conn.meshId) continue;
|
||||||
if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec)
|
|
||||||
continue;
|
if (isBroadcast) {
|
||||||
void maybePushQueuedMessages(pid);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +550,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
displayName: p.displayName,
|
displayName: p.displayName,
|
||||||
status: p.status as "idle" | "working" | "dnd",
|
status: p.status as "idle" | "working" | "dnd",
|
||||||
summary: p.summary,
|
summary: p.summary,
|
||||||
|
groups: p.groups,
|
||||||
sessionId: p.sessionId,
|
sessionId: p.sessionId,
|
||||||
connectedAt: p.connectedAt.toISOString(),
|
connectedAt: p.connectedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
@@ -528,6 +572,27 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
});
|
});
|
||||||
break;
|
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) {
|
} catch (e) {
|
||||||
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
|
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ export interface WSHelloMessage {
|
|||||||
meshId: string;
|
meshId: string;
|
||||||
memberId: string;
|
memberId: string;
|
||||||
pubkey: string; // must match mesh.member.peerPubkey
|
pubkey: string; // must match mesh.member.peerPubkey
|
||||||
|
sessionPubkey?: string; // ephemeral per-launch pubkey for message routing
|
||||||
displayName?: string; // optional override for this session
|
displayName?: string; // optional override for this session
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
cwd: string;
|
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. */
|
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
/** ed25519 signature (hex) over the canonical hello bytes:
|
/** ed25519 signature (hex) over the canonical hello bytes:
|
||||||
@@ -102,6 +105,19 @@ export interface WSSetSummaryMessage {
|
|||||||
summary: string;
|
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. */
|
/** Broker → client: acknowledgement for a send. */
|
||||||
export interface WSAckMessage {
|
export interface WSAckMessage {
|
||||||
type: "ack";
|
type: "ack";
|
||||||
@@ -125,6 +141,7 @@ export interface WSPeersListMessage {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
status: PeerStatus;
|
status: PeerStatus;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
connectedAt: string;
|
connectedAt: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -143,7 +160,9 @@ export type WSClientMessage =
|
|||||||
| WSSendMessage
|
| WSSendMessage
|
||||||
| WSSetStatusMessage
|
| WSSetStatusMessage
|
||||||
| WSListPeersMessage
|
| WSListPeersMessage
|
||||||
| WSSetSummaryMessage;
|
| WSSetSummaryMessage
|
||||||
|
| WSJoinGroupMessage
|
||||||
|
| WSLeaveGroupMessage;
|
||||||
|
|
||||||
export type WSServerMessage =
|
export type WSServerMessage =
|
||||||
| WSHelloAckMessage
|
| WSHelloAckMessage
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.1.8",
|
"version": "0.2.0",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { parseInviteLink } from "../invite/parse";
|
|||||||
import { enrollWithBroker } from "../invite/enroll";
|
import { enrollWithBroker } from "../invite/enroll";
|
||||||
import { generateKeypair } from "../crypto/keypair";
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||||
import { hostname } from "node:os";
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { homedir, hostname } from "node:os";
|
||||||
|
import { env } from "../env";
|
||||||
|
|
||||||
export async function runJoin(args: string[]): Promise<void> {
|
export async function runJoin(args: string[]): Promise<void> {
|
||||||
const link = args[0];
|
const link = args[0];
|
||||||
@@ -78,6 +81,16 @@ export async function runJoin(args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
|
||||||
|
// 4b. Store invite token for per-session re-enrollment (launch --name).
|
||||||
|
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
|
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(inviteFile), { recursive: true });
|
||||||
|
writeFileSync(inviteFile, link, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — launch will fall back to shared identity.
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Report.
|
// 5. Report.
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -11,32 +11,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
||||||
import { tmpdir, hostname } from "node:os";
|
import { tmpdir, hostname } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { loadConfig, getConfigPath } from "../state/config";
|
import { loadConfig, getConfigPath } from "../state/config";
|
||||||
import type { Config, JoinedMesh } from "../state/config";
|
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||||
import { generateKeypair } from "../crypto/keypair";
|
|
||||||
import { enrollWithBroker } from "../invite/enroll";
|
|
||||||
import { parseInviteLink } from "../invite/parse";
|
|
||||||
|
|
||||||
// --- Arg parsing ---
|
// --- Arg parsing ---
|
||||||
|
|
||||||
interface LaunchArgs {
|
interface LaunchArgs {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
|
||||||
joinLink: string | null;
|
joinLink: string | null;
|
||||||
meshSlug: string | null;
|
meshSlug: string | null;
|
||||||
quiet: boolean;
|
quiet: boolean;
|
||||||
|
skipPermConfirm: boolean;
|
||||||
claudeArgs: string[];
|
claudeArgs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]): LaunchArgs {
|
function parseArgs(argv: string[]): LaunchArgs {
|
||||||
const result: LaunchArgs = {
|
const result: LaunchArgs = {
|
||||||
name: null,
|
name: null,
|
||||||
|
role: null,
|
||||||
|
groups: null,
|
||||||
joinLink: null,
|
joinLink: null,
|
||||||
meshSlug: null,
|
meshSlug: null,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
skipPermConfirm: false,
|
||||||
claudeArgs: [],
|
claudeArgs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,6 +50,14 @@ function parseArgs(argv: string[]): LaunchArgs {
|
|||||||
result.name = argv[++i]!;
|
result.name = argv[++i]!;
|
||||||
} else if (arg.startsWith("--name=")) {
|
} else if (arg.startsWith("--name=")) {
|
||||||
result.name = arg.slice("--name=".length);
|
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) {
|
} else if (arg === "--join" && i + 1 < argv.length) {
|
||||||
result.joinLink = argv[++i]!;
|
result.joinLink = argv[++i]!;
|
||||||
} else if (arg.startsWith("--join=")) {
|
} else if (arg.startsWith("--join=")) {
|
||||||
@@ -57,6 +68,8 @@ function parseArgs(argv: string[]): LaunchArgs {
|
|||||||
result.meshSlug = arg.slice("--mesh=".length);
|
result.meshSlug = arg.slice("--mesh=".length);
|
||||||
} else if (arg === "--quiet") {
|
} else if (arg === "--quiet") {
|
||||||
result.quiet = true;
|
result.quiet = true;
|
||||||
|
} else if (arg === "-y" || arg === "--yes") {
|
||||||
|
result.skipPermConfirm = true;
|
||||||
} else if (arg === "--") {
|
} else if (arg === "--") {
|
||||||
result.claudeArgs.push(...argv.slice(i + 1));
|
result.claudeArgs.push(...argv.slice(i + 1));
|
||||||
break;
|
break;
|
||||||
@@ -94,16 +107,83 @@ 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> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
console.log(yellow(bold(" Autonomous mode")));
|
||||||
|
console.log("");
|
||||||
|
console.log(" Claude will send and receive peer messages without asking");
|
||||||
|
console.log(" you first. Peers exchange text only — no file access,");
|
||||||
|
console.log(" no tool calls, no code execution.");
|
||||||
|
console.log("");
|
||||||
|
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
||||||
|
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const a = answer.trim().toLowerCase();
|
||||||
|
if (a === "" || a === "y" || a === "yes") {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
console.log("\n Aborted. Run without autonomous mode:");
|
||||||
|
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Banner ---
|
// --- Banner ---
|
||||||
|
|
||||||
function printBanner(name: string, meshSlug: string): void {
|
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[]): void {
|
||||||
const useColor =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
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 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);
|
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(rule);
|
||||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||||
console.log("Peers send text only — they cannot call tools or read files.");
|
console.log("Peers send text only — they cannot call tools or read files.");
|
||||||
@@ -174,16 +254,45 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
mesh = await pickMesh(config.meshes);
|
mesh = await pickMesh(config.meshes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Set display name. Uses existing member identity — the broker
|
// 3. Session identity + role/groups.
|
||||||
// creates a separate presence row per session (sessionId + pid)
|
// The WS client auto-generates a per-session ephemeral keypair on
|
||||||
// and stores the per-session displayName override.
|
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
||||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
// 4. Write session config to tmpdir (same mesh, same keypair).
|
// 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 {
|
||||||
|
for (const entry of readdirSync(tmpBase)) {
|
||||||
|
if (!entry.startsWith("claudemesh-")) continue;
|
||||||
|
const full = join(tmpBase, entry);
|
||||||
|
const age = Date.now() - statSync(full).mtimeMs;
|
||||||
|
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
|
||||||
|
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||||
const sessionConfig: Config = {
|
const sessionConfig: Config = {
|
||||||
version: 1,
|
version: 1,
|
||||||
meshes: [mesh],
|
meshes: [mesh],
|
||||||
|
displayName,
|
||||||
|
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||||
};
|
};
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(tmpDir, "config.json"),
|
join(tmpDir, "config.json"),
|
||||||
@@ -191,14 +300,31 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Banner.
|
// 5. Banner + permission confirmation.
|
||||||
if (!args.quiet) printBanner(displayName, mesh.slug);
|
if (!args.quiet) {
|
||||||
|
printBanner(displayName, mesh.slug, role, parsedGroups);
|
||||||
|
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||||
|
if (!args.skipPermConfirm) {
|
||||||
|
await confirmPermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Spawn claude with ephemeral config + dev channel + display name.
|
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
|
||||||
|
// Strip any user-supplied --dangerously flags to avoid duplicates.
|
||||||
|
const filtered: string[] = [];
|
||||||
|
for (let i = 0; i < args.claudeArgs.length; i++) {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|
||||||
|
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.push(args.claudeArgs[i]!);
|
||||||
|
}
|
||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
...args.claudeArgs,
|
"--dangerously-skip-permissions",
|
||||||
|
...filtered,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ async function resolveClient(to: string): Promise<{
|
|||||||
target = rest;
|
target = rest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pubkey, channel, or broadcast — pass through directly.
|
// Pubkey, channel, @group, or broadcast — pass through directly.
|
||||||
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target === "*") {
|
if (/^[0-9a-f]{64}$/.test(target) || target.startsWith("#") || target.startsWith("@") || target === "*") {
|
||||||
if (targetClients.length === 1) {
|
if (targetClients.length === 1) {
|
||||||
return { client: targetClients[0]!, targetSpec: target };
|
return { client: targetClients[0]!, targetSpec: target };
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,24 @@ async function resolveClient(to: string): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Peer name cache to avoid calling listPeers on every incoming push
|
||||||
|
const peerNameCache = new Map<string, string>();
|
||||||
|
let peerNameCacheAge = 0;
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
async function resolvePeerName(client: BrokerClient, pubkey: string): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - peerNameCacheAge > CACHE_TTL_MS) {
|
||||||
|
peerNameCache.clear();
|
||||||
|
try {
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
for (const p of peers) peerNameCache.set(p.pubkey, p.displayName);
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
peerNameCacheAge = now;
|
||||||
|
}
|
||||||
|
return peerNameCache.get(pubkey) ?? `peer-${pubkey.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function decryptFailedWarning(senderPubkey: string): string {
|
function decryptFailedWarning(senderPubkey: string): string {
|
||||||
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
|
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
|
||||||
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
|
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
|
||||||
@@ -122,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.
|
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 the same target (for direct messages the from_id is the sender's pubkey).
|
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:
|
Available tools:
|
||||||
- list_peers: see joined meshes + their connection status
|
- 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)
|
- check_messages: drain buffered inbound messages (usually auto-pushed)
|
||||||
- set_summary: 1-2 sentence summary of what you're working on
|
- set_summary: 1-2 sentence summary of what you're working on
|
||||||
- set_status: manually override your status (idle/working/dnd)
|
- set_status: manually override your status (idle/working/dnd)
|
||||||
|
- join_group: join a @group with optional role
|
||||||
|
- leave_group: leave a @group
|
||||||
|
|
||||||
Message priority:
|
Message priority:
|
||||||
- "now": delivered immediately regardless of recipient status (use sparingly)
|
- "now": delivered immediately regardless of recipient status (use sparingly)
|
||||||
@@ -197,7 +217,8 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
} else {
|
} else {
|
||||||
const peerLines = peers.map((p) => {
|
const peerLines = peers.map((p) => {
|
||||||
const summary = p.summary ? ` — "${p.summary}"` : "";
|
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")}`);
|
sections.push(`${header}\n${peerLines.join("\n")}`);
|
||||||
}
|
}
|
||||||
@@ -234,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).`);
|
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:
|
default:
|
||||||
return text(`Unknown tool: ${name}`, true);
|
return text(`Unknown tool: ${name}`, true);
|
||||||
}
|
}
|
||||||
@@ -251,8 +286,9 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
|
|||||||
for (const client of allClients()) {
|
for (const client of allClients()) {
|
||||||
client.onPush(async (msg) => {
|
client.onPush(async (msg) => {
|
||||||
const fromPubkey = msg.senderPubkey || "";
|
const fromPubkey = msg.senderPubkey || "";
|
||||||
|
// Resolve sender's display name from the cached peer list.
|
||||||
const fromName = fromPubkey
|
const fromName = fromPubkey
|
||||||
? `peer-${fromPubkey.slice(0, 8)}`
|
? await resolvePeerName(client, fromPubkey)
|
||||||
: "unknown";
|
: "unknown";
|
||||||
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
|
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
name: "send_message",
|
name: "send_message",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Peer name, pubkey, or #channel",
|
description: "Peer name, pubkey, @group, or #channel",
|
||||||
},
|
},
|
||||||
message: { type: "string", description: "Message text" },
|
message: { type: "string", description: "Message text" },
|
||||||
priority: {
|
priority: {
|
||||||
@@ -78,4 +78,31 @@ export const TOOLS: Tool[] = [
|
|||||||
required: ["status"],
|
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,9 +28,16 @@ export interface JoinedMesh {
|
|||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupEntry {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
version: 1;
|
version: 1;
|
||||||
meshes: JoinedMesh[];
|
meshes: JoinedMesh[];
|
||||||
|
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||||
|
groups?: GroupEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
@@ -46,7 +53,7 @@ export function loadConfig(): Config {
|
|||||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||||
return { version: 1, meshes: [] };
|
return { version: 1, meshes: [] };
|
||||||
}
|
}
|
||||||
return { version: 1, meshes: parsed.meshes };
|
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
isDirectTarget,
|
isDirectTarget,
|
||||||
} from "../crypto/envelope";
|
} from "../crypto/envelope";
|
||||||
import { signHello } from "../crypto/hello-sig";
|
import { signHello } from "../crypto/hello-sig";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
export type Priority = "now" | "next" | "low";
|
export type Priority = "now" | "next" | "low";
|
||||||
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||||
@@ -30,6 +31,7 @@ export interface PeerInfo {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
status: string;
|
status: string;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
connectedAt: string;
|
connectedAt: string;
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,8 @@ export class BrokerClient {
|
|||||||
private pushHandlers = new Set<PushHandler>();
|
private pushHandlers = new Set<PushHandler>();
|
||||||
private pushBuffer: InboundPush[] = [];
|
private pushBuffer: InboundPush[] = [];
|
||||||
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
|
private listPeersResolvers: Array<(peers: PeerInfo[]) => void> = [];
|
||||||
|
private sessionPubkey: string | null = null;
|
||||||
|
private sessionSecretKey: string | null = null;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private reconnectAttempt = 0;
|
private reconnectAttempt = 0;
|
||||||
private helloTimer: NodeJS.Timeout | null = null;
|
private helloTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -83,6 +87,7 @@ export class BrokerClient {
|
|||||||
private mesh: JoinedMesh,
|
private mesh: JoinedMesh,
|
||||||
private opts: {
|
private opts: {
|
||||||
onStatusChange?: (status: ConnStatus) => void;
|
onStatusChange?: (status: ConnStatus) => void;
|
||||||
|
displayName?: string;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {}
|
) {}
|
||||||
@@ -109,8 +114,15 @@ export class BrokerClient {
|
|||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const onOpen = async (): Promise<void> => {
|
const onOpen = async (): Promise<void> => {
|
||||||
this.debug("ws open → signing + sending hello");
|
this.debug("ws open → generating session keypair + signing hello");
|
||||||
try {
|
try {
|
||||||
|
// Only generate session keypair on first connect, not reconnects
|
||||||
|
if (!this.sessionPubkey) {
|
||||||
|
const sessionKP = await generateKeypair();
|
||||||
|
this.sessionPubkey = sessionKP.publicKey;
|
||||||
|
this.sessionSecretKey = sessionKP.secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
const { timestamp, signature } = await signHello(
|
const { timestamp, signature } = await signHello(
|
||||||
this.mesh.meshId,
|
this.mesh.meshId,
|
||||||
this.mesh.memberId,
|
this.mesh.memberId,
|
||||||
@@ -123,7 +135,8 @@ export class BrokerClient {
|
|||||||
meshId: this.mesh.meshId,
|
meshId: this.mesh.meshId,
|
||||||
memberId: this.mesh.memberId,
|
memberId: this.mesh.memberId,
|
||||||
pubkey: this.mesh.pubkey,
|
pubkey: this.mesh.pubkey,
|
||||||
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || undefined,
|
sessionPubkey: this.sessionPubkey,
|
||||||
|
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || this.opts.displayName || undefined,
|
||||||
sessionId: `${process.pid}-${Date.now()}`,
|
sessionId: `${process.pid}-${Date.now()}`,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -203,7 +216,7 @@ export class BrokerClient {
|
|||||||
const env = await encryptDirect(
|
const env = await encryptDirect(
|
||||||
message,
|
message,
|
||||||
targetSpec,
|
targetSpec,
|
||||||
this.mesh.secretKey,
|
this.sessionSecretKey ?? this.mesh.secretKey,
|
||||||
);
|
);
|
||||||
nonce = env.nonce;
|
nonce = env.nonce;
|
||||||
ciphertext = env.ciphertext;
|
ciphertext = env.ciphertext;
|
||||||
@@ -300,6 +313,18 @@ export class BrokerClient {
|
|||||||
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
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 {
|
close(): void {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||||
@@ -349,7 +374,7 @@ export class BrokerClient {
|
|||||||
plaintext = await decryptDirect(
|
plaintext = await decryptDirect(
|
||||||
{ nonce, ciphertext },
|
{ nonce, ciphertext },
|
||||||
senderPubkey,
|
senderPubkey,
|
||||||
this.mesh.secretKey,
|
this.sessionSecretKey ?? this.mesh.secretKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Legacy/broadcast path: no senderPubkey means the message
|
// Legacy/broadcast path: no senderPubkey means the message
|
||||||
@@ -366,6 +391,19 @@ export class BrokerClient {
|
|||||||
plaintext = null;
|
plaintext = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback: if direct decrypt failed, try plaintext base64 decode.
|
||||||
|
// This handles broadcasts and key mismatches gracefully.
|
||||||
|
if (plaintext === null && ciphertext) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||||
|
// Sanity check: valid UTF-8 text (not binary garbage)
|
||||||
|
if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) {
|
||||||
|
plaintext = decoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
plaintext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const push: InboundPush = {
|
const push: InboundPush = {
|
||||||
messageId: String(msg.messageId ?? ""),
|
messageId: String(msg.messageId ?? ""),
|
||||||
meshId: String(msg.meshId ?? ""),
|
meshId: String(msg.meshId ?? ""),
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import type { Config, JoinedMesh } from "../state/config";
|
|||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
|
|
||||||
const clients = new Map<string, BrokerClient>();
|
const clients = new Map<string, BrokerClient>();
|
||||||
|
let configDisplayName: string | undefined;
|
||||||
|
|
||||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||||
const existing = clients.get(mesh.meshId);
|
const existing = clients.get(mesh.meshId);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG });
|
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG, displayName: configDisplayName });
|
||||||
clients.set(mesh.meshId, client);
|
clients.set(mesh.meshId, client);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -29,6 +30,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
|
|
||||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||||
export async function startClients(config: Config): Promise<void> {
|
export async function startClients(config: Config): Promise<void> {
|
||||||
|
configDisplayName = config.displayName;
|
||||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mesh"."presence" ADD COLUMN "session_pubkey" text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mesh"."message_queue" ADD COLUMN "sender_session_pubkey" text;
|
||||||
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,
|
"when": 1775463897329,
|
||||||
"tag": "0003_add-presence-summary",
|
"tag": "0003_add-presence-summary",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -192,6 +192,7 @@ export const presence = meshSchema.table("presence", {
|
|||||||
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
sessionId: text().notNull(),
|
sessionId: text().notNull(),
|
||||||
|
sessionPubkey: text(),
|
||||||
displayName: text(),
|
displayName: text(),
|
||||||
pid: integer().notNull(),
|
pid: integer().notNull(),
|
||||||
cwd: text().notNull(),
|
cwd: text().notNull(),
|
||||||
@@ -199,6 +200,7 @@ export const presence = meshSchema.table("presence", {
|
|||||||
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
|
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
|
||||||
statusUpdatedAt: timestamp().defaultNow().notNull(),
|
statusUpdatedAt: timestamp().defaultNow().notNull(),
|
||||||
summary: text(),
|
summary: text(),
|
||||||
|
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
|
||||||
connectedAt: timestamp().defaultNow().notNull(),
|
connectedAt: timestamp().defaultNow().notNull(),
|
||||||
lastPingAt: timestamp().defaultNow().notNull(),
|
lastPingAt: timestamp().defaultNow().notNull(),
|
||||||
disconnectedAt: timestamp(),
|
disconnectedAt: timestamp(),
|
||||||
@@ -221,6 +223,7 @@ export const messageQueue = meshSchema.table("message_queue", {
|
|||||||
senderMemberId: text()
|
senderMemberId: text()
|
||||||
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
senderSessionPubkey: text(),
|
||||||
targetSpec: text().notNull(),
|
targetSpec: text().notNull(),
|
||||||
priority: messagePriorityEnum().notNull().default("next"),
|
priority: messagePriorityEnum().notNull().default("next"),
|
||||||
nonce: text().notNull(),
|
nonce: text().notNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user