@sym-bot/mesh-channel 0.1.22 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,15 +5,15 @@
5
5
  "email": "info@sym.bot"
6
6
  },
7
7
  "metadata": {
8
- "description": "Real-time Claude-to-Claude mesh. Peer-to-peer cognitive signals over Bonjour LAN or WebSocket relay.",
9
- "version": "0.1.22"
8
+ "description": "Real-time Claude-to-Claude mesh. Agent-to-agent cognitive signals over Bonjour LAN or WebSocket relay.",
9
+ "version": "0.2.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "sym-mesh-channel",
14
14
  "source": "./",
15
- "description": "Real-time Claude-to-Claude mesh. Peer-to-peer cognitive signals over Bonjour LAN or WebSocket relay. Implements the Mesh Memory Protocol (MMP) for structured cognitive state exchange between Claude Code sessions.",
16
- "version": "0.1.22",
15
+ "description": "Real-time Claude-to-Claude mesh. Agent-to-agent cognitive signals over Bonjour LAN or WebSocket relay. Implements the Mesh Memory Protocol (MMP) for structured cognitive state exchange between Claude Code sessions.",
16
+ "version": "0.2.0",
17
17
  "author": {
18
18
  "name": "Hongwei Xu",
19
19
  "email": "hongwei@sym.bot"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sym-mesh-channel",
3
- "version": "0.1.22",
4
- "description": "Real-time Claude-to-Claude mesh. Peer-to-peer cognitive signals over Bonjour LAN or WebSocket relay.",
3
+ "version": "0.2.0",
4
+ "description": "Real-time Claude-to-Claude mesh. Agent-to-agent cognitive signals over Bonjour LAN or WebSocket relay.",
5
5
  "author": {
6
6
  "name": "Hongwei Xu",
7
7
  "email": "hongwei@sym.bot",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Breaking
6
+
7
+ - **`sym_send` tool signature change.** `sym_send` now emits a structured
8
+ CAT7 CMB (MMP §4.2) instead of a raw-text `type:'message'` frame, and
9
+ accepts an optional `to` parameter for targeted single-peer delivery
10
+ per MMP §4.4.4.
11
+
12
+ Old signature: `sym_send(message: string)`
13
+ New signature: `sym_send(focus: string (required), issue?, intent?,
14
+ motivation?, commitment?, perspective?, mood?, to?)`
15
+
16
+ Migration: agents that previously called `sym_send({message: "..."})`
17
+ should now pass the CAT7 fields explicitly, with `focus` carrying the
18
+ task anchor for the send. Prior ephemeral text-broadcast behaviour is
19
+ no longer exposed at the tool surface — `sym_send` and `sym_observe`
20
+ both emit CMBs now, receivers run SVAF per §9.2, and admitted CMBs are
21
+ remix-stored with lineage. The low-level `node.send(text)` SDK API is
22
+ unchanged but no longer surfaced as a tool.
23
+
24
+ ### Added
25
+
26
+ - **Targeted CMB send.** `sym_send` resolves `to` against connected
27
+ peers by full nodeId first, then display name, then 8-char prefix.
28
+ Ambiguous matches return an error asking for the full nodeId; a
29
+ disconnected target returns an error and suggests `sym_peers`.
30
+ - **Tool descriptions** for `sym_send` and `sym_observe` now explicitly
31
+ call out the SVAF receive path and lineage semantics, and the MCP
32
+ server's `instructions` string reflects the new division of labour.
33
+ - **`@sym-bot/sym` dependency bumped to `^0.3.81`** for
34
+ `remember(fields, {to})` targeted variant and `peers().peerId`.
35
+
36
+ ## 0.1.23
37
+
38
+ ### Added
39
+
40
+ - **`sym_join_group(group, relay_url?, relay_token?)`** — hot-swap this
41
+ node into a different mesh group at runtime, no Claude Code restart.
42
+ Stops the current SymNode, reconstructs it on the new service type
43
+ (and optional relay), re-registers event handlers, restarts. The
44
+ "smooth way to join" that was missing in 0.1.22.
45
+
46
+ - **`sym_invite_create(group, relay_url?, relay_token?)`** — generate
47
+ a shareable invite URL for a named group. Two flavors:
48
+ - LAN-only: `sym://group/{name}` (Bonjour isolation only)
49
+ - Cross-network: `sym://team/{name}?relay=...&token=...` (routes via
50
+ a WebSocket relay so teammates on different networks can join).
51
+ Validates kebab-case group names, rejects token without URL.
52
+
53
+ - **`sym_invite_info(url)`** extended to parse the new `sym://team/`
54
+ path and the `relay=` + `token=` query-string parameters.
55
+ Output now includes a ready-to-paste `sym_join_group` call as JSON.
56
+
57
+ - **`sym_groups_discover()`** — enumerate SYM-mesh groups currently
58
+ advertising on the local LAN via Bonjour / mDNS. Shell-outs to
59
+ `dns-sd` (macOS/Windows) or `avahi-browse` (Linux) with a 2-second
60
+ timeout, filters to service types matching the SYM protocol family
61
+ (global `_sym._tcp`, named groups, `{app}-{id}` rooms). Peer-to-peer
62
+ means only groups with live members right now are visible — no
63
+ central directory.
64
+
65
+ - **README — "Dev-team groups" walkthrough** with two concrete scenarios:
66
+ LAN dev-team group (single office) and cross-network team group via
67
+ the public `wss://sym-relay.onrender.com` relay. Shows exact tool
68
+ calls from both the team lead and each teammate.
69
+
70
+ - **13 new tests** covering invite URL parse, generate, round-trip, and
71
+ validation (kebab-case, token-requires-URL guard). Test suite now at
72
+ 35 tests total.
73
+
74
+ ### Changed
75
+
76
+ - Module-level `node`, `GROUP`, `SERVICE_TYPE`, `RELAY_URL`,
77
+ `RELAY_TOKEN` declared as `let` (was `const`) so the hot-swap path
78
+ can re-bind them. All node event handlers (`identity-collision`,
79
+ `cmb-accepted`, `message`) extracted into a single
80
+ `registerNodeHandlers(n)` function so the hot-swap path re-attaches
81
+ them without duplicating logic.
82
+
83
+ - Tool count in README corrected to 11 (was 8 in 0.1.22):
84
+ + sym_invite_create, sym_join_group, sym_groups_discover.
85
+
3
86
  ## 0.1.22
4
87
 
5
88
  ### Added
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  > MCP server that turns Claude Code into a peer node on the [SYM mesh](https://sym.bot) — the first non-Anthropic implementation of Claude Code Channels for real-time agent-to-agent cognition.
10
10
 
11
- Two Claude Code sessions on different machines discover each other via Bonjour mDNS, form a peer-to-peer mesh, and exchange structured cognitive signals in real-time. Each side is a full peer with its own cryptographic identity, its own [SVAF](https://arxiv.org/abs/2604.03955) receiver-side gating, and its own memory — not a thin client. Signals arrive mid-conversation as `<channel>` notifications. No polling, no shared server, no orchestrator.
11
+ Two Claude Code sessions on different machines discover each other via Bonjour mDNS, form a mesh, and exchange structured agent-to-agent cognitive signals in real-time. Each side is a full peer with its own cryptographic identity, its own [SVAF](https://arxiv.org/abs/2604.03955) receiver-side gating, and its own memory — not a thin client. Signals arrive mid-conversation as `<channel>` notifications. No polling, no shared server, no orchestrator.
12
12
 
13
13
  **Verified cross-platform:** Mac ↔ Windows on the same wifi, pure Bonjour, no relay, no token. Cross-network via optional WebSocket relay.
14
14
 
@@ -80,6 +80,88 @@ The other peer sees it arrive **in their Claude Code context as a real-time `<ch
80
80
 
81
81
  For cross-network setup (different offices, remote team), see [Cross-network setup](#cross-network-setup-optional) below.
82
82
 
83
+ ## Dev-team groups
84
+
85
+ By default every sym-mesh-channel node joins the global `_sym._tcp` mesh — every peer on the network sees every other peer. For a big company with multiple dev teams, this is too noisy. Mesh groups (MMP §5.8) isolate each team at the mDNS layer so `backend-team` and `frontend-team` can't see each other's CMBs at all.
86
+
87
+ ### LAN dev-team group (same office)
88
+
89
+ **Team lead creates the group:**
90
+
91
+ ```
92
+ # In Claude Code:
93
+ > sym_invite_create { "group": "backend-team" }
94
+
95
+ Invite URL (LAN-only (Bonjour)):
96
+
97
+ sym://group/backend-team
98
+
99
+ Share this URL with teammates...
100
+
101
+ > sym_join_group { "group": "backend-team" }
102
+
103
+ Hot-swapped from group "default" (_sym._tcp) to "backend-team" (_backend-team._tcp).
104
+ ```
105
+
106
+ **Team lead shares the URL** (Slack, email, however) with teammates.
107
+
108
+ **Each teammate pastes the URL into their Claude Code session:**
109
+
110
+ ```
111
+ > sym_invite_info { "url": "sym://group/backend-team" }
112
+
113
+ Parsed invite: sym://group/backend-team
114
+ { "app": "sym", "group": "backend-team", "service_type": "_backend-team._tcp" }
115
+
116
+ To join, call sym_join_group:
117
+ { "group": "backend-team" }
118
+
119
+ > sym_join_group { "group": "backend-team" }
120
+
121
+ Hot-swapped from group "default" to "backend-team".
122
+ ```
123
+
124
+ No restart, no `~/.claude.json` editing. Teammates on the same LAN now see each other via Bonjour. `backend-team` and `frontend-team` live in isolated mDNS spaces.
125
+
126
+ ### Cross-network team group (distributed team via relay)
127
+
128
+ Same story, but the team crosses network boundaries (home ↔ office, coffee shop ↔ client site). You need a relay so members can find each other over the internet. We host one at `wss://sym-relay.onrender.com`; you can run your own from the [sym-relay](https://github.com/sym-bot/sym-relay) repo.
129
+
130
+ **Team lead creates the relay-backed invite:**
131
+
132
+ ```
133
+ > sym_invite_create {
134
+ "group": "eng-team",
135
+ "relay_url": "wss://sym-relay.onrender.com",
136
+ "relay_token": "a-shared-secret-any-string-teammates-agree-on"
137
+ }
138
+
139
+ Invite URL (cross-network (relay)):
140
+
141
+ sym://team/eng-team?relay=wss%3A%2F%2Fsym-relay.onrender.com&token=a-shared-secret-...
142
+
143
+ > sym_join_group {
144
+ "group": "eng-team",
145
+ "relay_url": "wss://sym-relay.onrender.com",
146
+ "relay_token": "a-shared-secret-any-string-teammates-agree-on"
147
+ }
148
+ ```
149
+
150
+ Teammate pastes the URL, `sym_invite_info` extracts the relay + token from the query string, `sym_join_group` hot-swaps with the same args. All members with the same token share one relay channel — different tokens = different channels on the same relay host.
151
+
152
+ ### Discovering what's out there
153
+
154
+ ```
155
+ > sym_groups_discover
156
+
157
+ SYM-mesh groups visible on LAN (3):
158
+ _sym._tcp group="sym"
159
+ _backend-team._tcp group="backend-team" (← your current group)
160
+ _frontend-team._tcp group="frontend-team"
161
+ ```
162
+
163
+ Only shows groups with at least one node online right now — there's no central directory of offline-but-known groups (decentralised architecture). For cross-network relay-backed groups, you must know the relay URL + token out of band (someone shares the invite URL).
164
+
83
165
  ### Advanced: per-project node identity
84
166
 
85
167
  By default every Claude Code session on a machine shares one mesh identity (set globally in `~/.claude.json`). If you run several Claude Code sessions in parallel from distinct project directories and want each to appear as its own peer on the mesh — e.g. a "research" session and a "strategy" session on the same laptop — install per-project instead:
@@ -105,7 +187,7 @@ The plugin is approved on the Anthropic Plugin Directory. The `--dangerously-loa
105
187
 
106
188
  ## What you get
107
189
 
108
- Eight MCP tools exposed to Claude Code, namespaced under `mcp__claude-sym-mesh__`:
190
+ Eleven MCP tools exposed to Claude Code, namespaced under `mcp__claude-sym-mesh__`:
109
191
 
110
192
  | Tool | What it does |
111
193
  |---|---|
@@ -116,7 +198,10 @@ Eight MCP tools exposed to Claude Code, namespaced under `mcp__claude-sym-mesh__
116
198
  | `sym_peers` | List discovered peers (via bonjour or relay). |
117
199
  | `sym_status` | Node identity, relay state, peer count, memory count. Includes current mesh group (MMP §5.8). |
118
200
  | `sym_group_info` | Report the mesh group this node is in, with service type + group name + peer roster scoped to the group. |
119
- | `sym_invite_info` | Parse an app-specific mesh invite URL (e.g. `melotune://room/{id}/{name}`) and return service type + group + optional relay token. Read-only; does not switch groups. |
201
+ | `sym_invite_create` | Generate a shareable invite URL for a named group. LAN-only `sym://group/{name}` or cross-network `sym://team/{name}?relay=&token=` flavor. Team leads share this with teammates. |
202
+ | `sym_invite_info` | Parse a mesh invite URL and return group + service type + relay creds + a ready-to-use `sym_join_group` call. Read-only; does not switch groups. |
203
+ | `sym_join_group` | **Hot-swap** this node into a different mesh group at runtime — no Claude Code restart. Works for LAN groups and cross-network relay-backed teams. |
204
+ | `sym_groups_discover` | List SYM-mesh groups currently advertising on the local network via Bonjour / mDNS. Only shows groups with at least one node online right now. |
120
205
 
121
206
  Real-time push is bidirectional: peer events arrive in Claude's context without any tool call, while the session is mid-turn. This is the "Claude thinks with the mesh" property — not "Claude pokes the mesh occasionally."
122
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sym-bot/mesh-channel",
3
- "version": "0.1.22",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server — real-time agent-to-agent cognition for Claude Code remote teams via the SYM mesh.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.12.1",
25
- "@sym-bot/sym": "^0.3.78"
25
+ "@sym-bot/sym": "^0.3.81"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18"
package/server.js CHANGED
@@ -30,6 +30,134 @@ const {
30
30
  } = require('@modelcontextprotocol/sdk/types.js');
31
31
  const { SymNode } = require('@sym-bot/sym');
32
32
 
33
+ // Kebab-case validator shared by group-related tools.
34
+ const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
35
+
36
+ // ── Invite URL parsing (shared by sym_invite_info and the internal
37
+ // validation path for sym_join_group when passed a URL). Exposed as
38
+ // a module-level function so it's trivially unit-testable and the
39
+ // same regex doesn't drift between two call sites.
40
+
41
+ const INVITE_URL_RE = /^([a-z][a-z0-9-]+):\/\/(?:room|group|team)\/([^/?#]+)(?:\/([^?#]+))?(?:\?(.+))?$/i;
42
+
43
+ function parseInviteURL(url) {
44
+ const m = INVITE_URL_RE.exec(url);
45
+ if (!m) {
46
+ return {
47
+ error:
48
+ `Unrecognised invite URL: ${url}\n\n` +
49
+ `Expected shapes:\n` +
50
+ ` sym://group/{name} (LAN-only)\n` +
51
+ ` sym://team/{name}?relay=...&token=... (cross-network via relay)\n` +
52
+ ` melotune://room/{id}/{name} (app-specific room)`,
53
+ };
54
+ }
55
+ const appScheme = m[1].toLowerCase();
56
+ const rawId = decodeURIComponent(m[2]);
57
+ const rawName = m[3] ? decodeURIComponent(m[3]) : rawId;
58
+ const queryStr = m[4] || '';
59
+ const query = Object.fromEntries(
60
+ queryStr.split('&').filter(Boolean).map(kv => {
61
+ const [k, v = ''] = kv.split('=');
62
+ return [decodeURIComponent(k), decodeURIComponent(v)];
63
+ })
64
+ );
65
+ // For sym:// the path element IS the group name. For app-scoped URLs
66
+ // (melotune://, melomove://, etc.) the path is the room id and the
67
+ // group is prefixed with the app name to avoid collisions.
68
+ const serviceType = appScheme === 'sym' ? `_${rawId}._tcp` : `_${appScheme}-${rawId}._tcp`;
69
+ const group = appScheme === 'sym' ? rawId : `${appScheme}-${rawId}`;
70
+ return {
71
+ appScheme,
72
+ group,
73
+ serviceType,
74
+ roomId: rawId,
75
+ roomName: rawName,
76
+ relayUrl: query.relay || null,
77
+ relayToken: query.token || null,
78
+ };
79
+ }
80
+
81
+ // ── Bonjour discovery of live SYM-related service types.
82
+ // Runs `dns-sd -B _services._dns-sd._udp local.` (macOS / Windows with
83
+ // Bonjour) or `avahi-browse -at` (Linux) for 2 seconds, filters to
84
+ // service types that look SYM-ish, and reports them. Pure observation,
85
+ // no node state changes.
86
+
87
+ async function discoverGroups() {
88
+ const { spawn } = require('child_process');
89
+ const platform = process.platform;
90
+
91
+ let cmd, argv;
92
+ if (platform === 'darwin' || platform === 'win32') {
93
+ cmd = 'dns-sd';
94
+ argv = ['-B', '_services._dns-sd._udp', 'local.'];
95
+ } else {
96
+ cmd = 'avahi-browse';
97
+ argv = ['-t', '-a', '-p']; // terminate after cache, all services, parseable
98
+ }
99
+
100
+ return new Promise((resolve) => {
101
+ let child;
102
+ try {
103
+ child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
104
+ } catch (e) {
105
+ return resolve({
106
+ isError: true,
107
+ text:
108
+ `Could not run discovery command '${cmd}': ${e?.message || e}\n\n` +
109
+ (platform === 'linux'
110
+ ? `On Linux, install avahi-utils: sudo apt install avahi-utils`
111
+ : `Bonjour should be built-in on macOS and Windows 10+.`),
112
+ });
113
+ }
114
+ const out = [];
115
+ child.stdout.on('data', (chunk) => out.push(chunk));
116
+ child.on('error', (e) => resolve({ isError: true, text: `Discovery command failed: ${e?.message || e}` }));
117
+
118
+ const timer = setTimeout(() => {
119
+ try { child.kill('SIGTERM'); } catch {}
120
+ }, 2000);
121
+ child.on('close', () => {
122
+ clearTimeout(timer);
123
+ const text = Buffer.concat(out).toString('utf8');
124
+ const typeRe = /_([a-z0-9][a-z0-9-]+)\._tcp/gi;
125
+ const seen = new Set();
126
+ let m;
127
+ while ((m = typeRe.exec(text)) !== null) {
128
+ const full = `_${m[1]}._tcp`;
129
+ // Filter to the SYM protocol family: global sym, named groups, and
130
+ // app-scoped rooms (melotune-<id>, melomove-<id>, etc). Anything
131
+ // that looks like generic infra (_services._dns-sd, _tcp, _udp,
132
+ // printer protocols, etc.) is ignored.
133
+ if (/^_(sym|[a-z]+-[a-z0-9]+|[a-z]+-team|.*-team)\._tcp$/i.test(full)) {
134
+ seen.add(full);
135
+ }
136
+ }
137
+ if (seen.size === 0) {
138
+ return resolve({
139
+ text:
140
+ `No SYM-mesh groups visible on the local network right now.\n\n` +
141
+ `This only shows groups with at least one node currently online. ` +
142
+ `Groups you or teammates have used before are not persisted anywhere ` +
143
+ `(p2p architecture — no central directory).\n\n` +
144
+ `Your node is on: ${SERVICE_TYPE} (group "${GROUP}").`,
145
+ });
146
+ }
147
+ const lines = [];
148
+ lines.push(`SYM-mesh groups visible on LAN (${seen.size}):`);
149
+ for (const st of Array.from(seen).sort()) {
150
+ const name = st.replace(/^_/, '').replace(/\._tcp$/, '');
151
+ const isSelf = st === SERVICE_TYPE ? ' (← your current group)' : '';
152
+ lines.push(` ${st} group="${name}"${isSelf}`);
153
+ }
154
+ lines.push('');
155
+ lines.push(`To join one, call sym_join_group with group="<name>".`);
156
+ resolve({ text: lines.join('\n') });
157
+ });
158
+ });
159
+ }
160
+
33
161
  // ── Engineering-domain field weights (SVAF α_f) ──────────────
34
162
 
35
163
  const FIELD_WEIGHTS = {
@@ -65,35 +193,63 @@ function resolveServiceType() {
65
193
  if (group && group !== 'default') return `_${group}._tcp`;
66
194
  return '_sym._tcp';
67
195
  }
68
- const SERVICE_TYPE = resolveServiceType();
69
- const GROUP = process.env.SYM_GROUP || (SERVICE_TYPE !== '_sym._tcp'
196
+ // Mutable so sym_join_group can hot-swap the node at runtime without a
197
+ // Claude Code restart. Declaring as `let` rather than `const` is the
198
+ // smallest change that makes hot-swap possible.
199
+ let SERVICE_TYPE = resolveServiceType();
200
+ let GROUP = process.env.SYM_GROUP || (SERVICE_TYPE !== '_sym._tcp'
70
201
  ? SERVICE_TYPE.replace(/^_/, '').replace(/\._tcp$/, '')
71
202
  : 'default');
203
+ let RELAY_URL = process.env.SYM_RELAY_URL || null;
204
+ let RELAY_TOKEN = process.env.SYM_RELAY_TOKEN || null;
72
205
 
73
- const node = new SymNode({
206
+ let node = new SymNode({
74
207
  name: NODE_NAME,
75
208
  cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
76
209
  svafFieldWeights: FIELD_WEIGHTS,
77
210
  svafFreshnessSeconds: 7200, // 2hr — session-length context
78
211
  discoveryServiceType: SERVICE_TYPE,
79
212
  group: GROUP,
80
- relay: process.env.SYM_RELAY_URL || null,
81
- relayToken: process.env.SYM_RELAY_TOKEN || null,
213
+ relay: RELAY_URL,
214
+ relayToken: RELAY_TOKEN,
82
215
  silent: true,
83
216
  });
84
217
 
85
- // Identity collision (added in @sym-bot/sym 0.3.68): the relay told us
86
- // another process is holding our nodeId. Don't try to reconnect — that
87
- // caused the peer-flap loop documented in v0.1.2/v0.1.3 commit messages.
88
- // Exit so Claude Code can decide whether to respawn (with the freshness
89
- // window now elapsed) or surface the failure to the user.
90
- node.on('identity-collision', (info) => {
91
- process.stderr.write(
92
- `sym-mesh-channel: identity collision on relay another process is holding ` +
93
- `nodeId=${info.nodeId} name=${info.name}. Exiting.\n`
94
- );
95
- process.exit(2);
96
- });
218
+ // Event handlers are extracted into a single registration function so the
219
+ // hot-swap path in sym_join_group can re-register them on the new node.
220
+ // The function reads module-level `NODE_NAME`, `isPeerAllowed`, `pushChannel`,
221
+ // `storeMessage`, and `extractCompactHeader` via closure; those don't change
222
+ // across swaps.
223
+ function registerNodeHandlers(n) {
224
+ // Identity collision (added in @sym-bot/sym 0.3.68): the relay told us
225
+ // another process is holding our nodeId. Don't try to reconnect — that
226
+ // caused the peer-flap loop documented in v0.1.2/v0.1.3 commit messages.
227
+ // Exit so Claude Code can decide whether to respawn (with the freshness
228
+ // window now elapsed) or surface the failure to the user.
229
+ n.on('identity-collision', (info) => {
230
+ process.stderr.write(
231
+ `sym-mesh-channel: identity collision on relay — another process is holding ` +
232
+ `nodeId=${info.nodeId} name=${info.name}. Exiting.\n`
233
+ );
234
+ process.exit(2);
235
+ });
236
+
237
+ n.on('cmb-accepted', (entry) => {
238
+ if (entry.source === NODE_NAME || entry.cmb?.createdBy === NODE_NAME) return;
239
+ const source = entry.source || entry.cmb?.createdBy || 'unknown';
240
+ if (!isPeerAllowed(source)) return;
241
+ const focus = entry.cmb?.fields?.focus?.text || entry.content || '';
242
+ const mood = entry.cmb?.fields?.mood?.text || '';
243
+ pushChannel('cmb', `[${source}] ${focus}${mood && mood !== 'neutral' ? ` (mood: ${mood})` : ''}`);
244
+ });
245
+
246
+ n.on('message', (from, content) => {
247
+ if (!isPeerAllowed(from)) return;
248
+ const msgId = storeMessage(from, content);
249
+ const header = extractCompactHeader(from, content);
250
+ pushChannel('message', `[${from}] ${header} [${msgId}]`);
251
+ });
252
+ }
97
253
 
98
254
  // ── MCP Server ───────────────────────────────────────────────
99
255
 
@@ -107,8 +263,9 @@ const mcp = new Server(
107
263
  instructions:
108
264
  `You are a peer node on the SYM mesh (identity: ${NODE_NAME}). ` +
109
265
  'Mesh events arrive as <channel> notifications in real-time. ' +
110
- 'When you see a message or CMB from another node, respond via the sym_send tool if actionable. ' +
111
- 'Share observations about the user\'s state via sym_observe. ' +
266
+ 'When you see a CMB from another node, respond via sym_send targeted at that node by name if the reply is for that specific peer (MMP §4.4.4 targeted CMB). ' +
267
+ 'Share observations about your own state with the whole mesh via sym_observe (MMP §9.2 receiver-autonomous SVAF evaluation). ' +
268
+ 'Both sym_send and sym_observe emit CAT7 CMBs; receivers run SVAF and, if admitted, remix-store with lineage pointing back to your CMB. ' +
112
269
  'Search mesh memory via sym_recall. ' +
113
270
  'Messages arrive as compact headers with [mNNN] IDs — use sym_fetch to read the full content when the header is relevant to your current task.',
114
271
  },
@@ -120,16 +277,44 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
120
277
  tools: [
121
278
  {
122
279
  name: 'sym_send',
123
- description: 'Send a message to all mesh peers. Stored as a persistent CMB and broadcast via relay.',
280
+ description:
281
+ 'Send a structured CAT7 CMB to a specific mesh peer (targeted) or to all peers (broadcast, when "to" is omitted). ' +
282
+ 'Receivers evaluate the CMB per-field via SVAF (MMP §9.2) and, if admitted, remix-store it with lineage pointing back to this CMB. ' +
283
+ 'Use sym_send when the CMB is for a specific peer (e.g. a peer-review gating request directed at the reviewer role); ' +
284
+ 'use sym_observe when sharing your own state with the whole mesh.',
124
285
  inputSchema: {
125
286
  type: 'object',
126
- properties: { message: { type: 'string', description: 'Message to broadcast' } },
127
- required: ['message'],
287
+ properties: {
288
+ focus: { type: 'string', description: 'The task anchor / what this CMB is about. Required.' },
289
+ issue: { type: 'string' },
290
+ intent: { type: 'string' },
291
+ motivation: { type: 'string' },
292
+ commitment: { type: 'string' },
293
+ perspective: { type: 'string' },
294
+ mood: {
295
+ type: 'object',
296
+ properties: {
297
+ text: { type: 'string' },
298
+ valence: { type: 'number' },
299
+ arousal: { type: 'number' },
300
+ },
301
+ },
302
+ to: {
303
+ type: 'string',
304
+ description:
305
+ 'Target peer: either the peer display name (e.g. "claude-research-win") or the full nodeId. ' +
306
+ 'Call sym_peers first if unsure which peers are connected. Omit to broadcast to all peers.',
307
+ },
308
+ },
309
+ required: ['focus'],
128
310
  },
129
311
  },
130
312
  {
131
313
  name: 'sym_observe',
132
- description: 'Share a structured CAT7 observation with the mesh. Extract fields from what you observe.',
314
+ description:
315
+ 'Broadcast a structured CAT7 observation about your own state to all mesh peers. ' +
316
+ 'Receivers run SVAF (MMP §9.2) and admitted CMBs are remix-stored with lineage. ' +
317
+ 'Equivalent to sym_send with "to" omitted — kept as a separate tool because self-observation is the common case and does not need peer selection.',
133
318
  inputSchema: {
134
319
  type: 'object',
135
320
  properties: {
@@ -184,15 +369,46 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
184
369
  description: 'Report the mesh group this node is in (MMP §5.8). Shows service type + group name + peer count.',
185
370
  inputSchema: { type: 'object', properties: {} },
186
371
  },
372
+ {
373
+ name: 'sym_invite_create',
374
+ description: 'Generate a shareable invite URL for a named mesh group. Team leads use this to let teammates join their dev-team mesh. LAN-only invite: pass group only, returns sym://group/{name}. Cross-network invite: pass relay_url + relay_token too, returns sym://team/{name}?relay=...&token=... — teammates on different networks join through the relay.',
375
+ inputSchema: {
376
+ type: 'object',
377
+ properties: {
378
+ group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team".' },
379
+ relay_url: { type: 'string', description: 'Optional WebSocket relay URL, e.g. wss://sym-relay.onrender.com. Include for cross-network teams.' },
380
+ relay_token: { type: 'string', description: 'Optional relay authentication token (shared secret for this team channel).' },
381
+ },
382
+ required: ['group'],
383
+ },
384
+ },
187
385
  {
188
386
  name: 'sym_invite_info',
189
- description: 'Return the service type + group + optional relay token encoded in an app-specific mesh invite URL (e.g. melotune://room/{id}/{name}). Read-only inspection; does NOT switch the current node.',
387
+ description: 'Parse a mesh invite URL and return everything the invitee needs to join: group name, service type, and any relay credentials. Read-only; does NOT switch the current node (use sym_join_group for that). Works on LAN group invites (sym://group/{name}), cross-network team invites (sym://team/{name}?relay=&token=), and app-specific room invites (e.g. melotune://room/{id}/{name}).',
190
388
  inputSchema: {
191
389
  type: 'object',
192
- properties: { url: { type: 'string', description: 'Invite URL, e.g. melotune://room/abc123/Kitchen' } },
390
+ properties: { url: { type: 'string', description: 'Invite URL, e.g. sym://group/backend-team' } },
193
391
  required: ['url'],
194
392
  },
195
393
  },
394
+ {
395
+ name: 'sym_join_group',
396
+ description: 'Hot-swap this node into a different mesh group at runtime — no Claude Code restart needed. Stops the current SymNode, reconstructs it with the new group (and optional relay credentials), and restarts it. Teammates on the same group/relay will discover this node via Bonjour (LAN) or the relay (cross-network). To leave a group, pass group="default" which reverts to the global _sym._tcp mesh.',
397
+ inputSchema: {
398
+ type: 'object',
399
+ properties: {
400
+ group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team". Pass "default" to return to the global mesh.' },
401
+ relay_url: { type: 'string', description: 'Optional WebSocket relay URL for cross-network teams. Leave empty for LAN-only.' },
402
+ relay_token: { type: 'string', description: 'Optional relay authentication token.' },
403
+ },
404
+ required: ['group'],
405
+ },
406
+ },
407
+ {
408
+ name: 'sym_groups_discover',
409
+ description: 'List SYM-mesh groups currently advertising on the local network. Uses Bonjour / mDNS to find service types matching the SYM protocol. Only shows groups with at least one node online right now — there is no central directory of offline-but-known groups. macOS and Windows have Bonjour built in; Linux requires avahi-daemon.',
410
+ inputSchema: { type: 'object', properties: {} },
411
+ },
196
412
  ],
197
413
  }));
198
414
 
@@ -201,22 +417,60 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
201
417
 
202
418
  switch (name) {
203
419
  case 'sym_send': {
204
- // Direct inter-node message broadcast as type:'message' frame only.
205
- // Do NOT also persist as a CMB via node.remember(): that caused
206
- // double-delivery on receivers, who saw the same payload arrive once
207
- // as event_type='message' (from this broadcast) and again as
208
- // event_type='cmb' (from CMB gossip replication). One tool, one job:
209
- // sym_send is for ephemeral inter-node messages; sym_observe is for
210
- // structured CAT7 CMBs. Hosts that want both should call both.
211
- //
212
- // Report the actual delivered count (the number of peer transports
213
- // that successfully accepted the broadcast), not peers().length.
214
- // The two can disagree when peers are in _peers but their transports
215
- // are broken counting peers().length would lie about delivery.
216
- // Requires @sym-bot/sym >= 0.3.70 where send() returns the count.
217
- const msg = args.message;
218
- const delivered = node.send(msg);
219
- return { content: [{ type: 'text', text: `Message delivered to ${delivered} peer(s).` }] };
420
+ // Emit a structured CAT7 CMB per MMP §4.2. When args.to names a peer,
421
+ // route as a targeted send (§4.4.4); otherwise broadcast. Receivers
422
+ // run SVAF (§9.2) and remix-store on accept no separate "message"
423
+ // frame path, no raw-text channel.
424
+ const fields = {
425
+ focus: args.focus || 'directive',
426
+ issue: args.issue || 'none',
427
+ intent: args.intent || 'directive',
428
+ motivation: args.motivation || '',
429
+ commitment: args.commitment || '',
430
+ perspective: args.perspective || NODE_NAME,
431
+ mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
432
+ };
433
+
434
+ let targetPeerId = null;
435
+ if (args.to) {
436
+ const peers = node.peers();
437
+ // Exact full-nodeId match first (unambiguous).
438
+ const byNodeId = peers.filter(p => p.peerId === args.to);
439
+ // Name match second.
440
+ const byName = peers.filter(p => p.name === args.to);
441
+ // Short-id prefix match last (for human-typed 8-char prefixes).
442
+ const byPrefix = peers.filter(p => p.id === args.to);
443
+
444
+ let matches;
445
+ if (byNodeId.length > 0) matches = byNodeId;
446
+ else if (byName.length > 0) matches = byName;
447
+ else if (byPrefix.length > 0) matches = byPrefix;
448
+ else matches = [];
449
+
450
+ if (matches.length === 0) {
451
+ return {
452
+ content: [{ type: 'text', text: `Peer "${args.to}" not connected. Call sym_peers to see connected peers.` }],
453
+ isError: true,
454
+ };
455
+ }
456
+ if (matches.length > 1) {
457
+ const names = matches.map(p => `${p.name} (${p.peerId})`).join(', ');
458
+ return {
459
+ content: [{ type: 'text', text: `Peer "${args.to}" is ambiguous — matches: ${names}. Pass the full nodeId.` }],
460
+ isError: true,
461
+ };
462
+ }
463
+ targetPeerId = matches[0].peerId;
464
+ }
465
+
466
+ const entry = node.remember(fields, targetPeerId ? { to: targetPeerId } : {});
467
+ if (!entry) {
468
+ return { content: [{ type: 'text', text: 'Duplicate — CMB already in memory, not re-broadcast.' }] };
469
+ }
470
+ const summary = targetPeerId
471
+ ? `Sent CMB ${entry.key} to ${args.to}`
472
+ : `Broadcast CMB ${entry.key} to all peers`;
473
+ return { content: [{ type: 'text', text: summary }] };
220
474
  }
221
475
 
222
476
  case 'sym_observe': {
@@ -303,41 +557,173 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
303
557
  };
304
558
  }
305
559
 
560
+ case 'sym_invite_create': {
561
+ const group = args?.group;
562
+ const relayUrl = args?.relay_url;
563
+ const relayToken = args?.relay_token;
564
+ if (!group || typeof group !== 'string') {
565
+ return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
566
+ }
567
+ if (!KEBAB_CASE_RE.test(group)) {
568
+ return {
569
+ content: [{
570
+ type: 'text',
571
+ text: `Invalid group name: "${group}". Must be kebab-case (lowercase alphanumerics + single hyphens), e.g. "backend-team".`,
572
+ }],
573
+ isError: true,
574
+ };
575
+ }
576
+ // LAN-only flavor: sym://group/{name}
577
+ // Cross-network flavor: sym://team/{name}?relay=...&token=...
578
+ let url;
579
+ let flavor;
580
+ if (relayUrl || relayToken) {
581
+ if (!relayUrl) return { content: [{ type: 'text', text: 'relay_token requires relay_url' }], isError: true };
582
+ const params = [`relay=${encodeURIComponent(relayUrl)}`];
583
+ if (relayToken) params.push(`token=${encodeURIComponent(relayToken)}`);
584
+ url = `sym://team/${group}?${params.join('&')}`;
585
+ flavor = 'cross-network (relay)';
586
+ } else {
587
+ url = `sym://group/${group}`;
588
+ flavor = 'LAN-only (Bonjour)';
589
+ }
590
+ const youRunning = GROUP === group
591
+ ? `You're already on this group — teammates who join will see you.`
592
+ : `You are currently on group "${GROUP}". To be reachable, call sym_join_group with group="${group}" (+ same relay creds if cross-network) before sharing.`;
593
+ return {
594
+ content: [{
595
+ type: 'text',
596
+ text: `Invite URL (${flavor}):\n\n ${url}\n\n` +
597
+ `Share this URL with teammates. Each pastes it into Claude Code and calls sym_join_group (or sym_invite_info for a dry run first).\n\n` +
598
+ youRunning,
599
+ }],
600
+ };
601
+ }
602
+
306
603
  case 'sym_invite_info': {
307
604
  const url = args?.url;
308
605
  if (!url || typeof url !== 'string') {
309
606
  return { content: [{ type: 'text', text: 'Missing required argument: url' }], isError: true };
310
607
  }
311
- // Supported scheme examples:
312
- // melotune://room/{id}/{percent-encoded name} (per MoodRoom.inviteURL in sym-swift)
313
- // sym://group/{name}
314
- const m = url.match(/^([a-z][a-z0-9-]+):\/\/(?:room|group)\/([^/?#]+)(?:\/([^?#]+))?/i);
315
- if (!m) {
316
- return { content: [{ type: 'text', text: `Unrecognised invite URL: ${url}` }], isError: true };
608
+ const parsed = parseInviteURL(url);
609
+ if (parsed.error) {
610
+ return { content: [{ type: 'text', text: parsed.error }], isError: true };
317
611
  }
318
- const appScheme = m[1].toLowerCase();
319
- const rawId = decodeURIComponent(m[2]);
320
- const rawName = m[3] ? decodeURIComponent(m[3]) : rawId;
321
- // Map to service type + group.
322
- const serviceType = appScheme === 'sym'
323
- ? `_${rawId}._tcp`
324
- : `_${appScheme}-${rawId}._tcp`;
325
- const group = appScheme === 'sym' ? rawId : `${appScheme}-${rawId}`;
612
+ const { appScheme, group, serviceType, roomId, roomName, relayUrl, relayToken } = parsed;
613
+
614
+ const out = {
615
+ app: appScheme,
616
+ group,
617
+ service_type: serviceType,
618
+ room_id: appScheme === 'sym' ? undefined : roomId,
619
+ room_name: appScheme === 'sym' ? undefined : roomName,
620
+ relay_url: relayUrl || undefined,
621
+ relay_token: relayToken || undefined,
622
+ };
623
+ for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
624
+
625
+ const joinCall = {
626
+ group,
627
+ ...(relayUrl && { relay_url: relayUrl }),
628
+ ...(relayToken && { relay_token: relayToken }),
629
+ };
630
+
326
631
  return {
327
632
  content: [{
328
633
  type: 'text',
329
- text: JSON.stringify({
330
- app: appScheme,
331
- group,
332
- service_type: serviceType,
333
- room_id: rawId,
334
- room_name: rawName,
335
- join_hint: `Set env vars: SYM_GROUP=${group} SYM_SERVICE_TYPE=${serviceType} — then restart the MCP server.`,
336
- }, null, 2),
634
+ text: `Parsed invite: ${url}\n\n` +
635
+ JSON.stringify(out, null, 2) + `\n\n` +
636
+ `To join, call sym_join_group:\n\n ${JSON.stringify(joinCall)}\n\n` +
637
+ `This hot-swaps your node into the ${relayUrl ? 'relay channel' : 'LAN group'} — no Claude Code restart needed.`,
337
638
  }],
338
639
  };
339
640
  }
340
641
 
642
+ case 'sym_join_group': {
643
+ const group = args?.group;
644
+ const relayUrl = args?.relay_url || null;
645
+ const relayToken = args?.relay_token || null;
646
+ if (!group || typeof group !== 'string') {
647
+ return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
648
+ }
649
+ if (!KEBAB_CASE_RE.test(group) && group !== 'default') {
650
+ return {
651
+ content: [{ type: 'text', text: `Invalid group name: "${group}". Must be kebab-case or "default".` }],
652
+ isError: true,
653
+ };
654
+ }
655
+
656
+ const newServiceType = group === 'default' ? '_sym._tcp' : `_${group}._tcp`;
657
+ const prevGroup = GROUP;
658
+ const prevServiceType = SERVICE_TYPE;
659
+
660
+ // Stop the current node cleanly so peers see us leave, then construct
661
+ // a fresh one on the new service type. Any failure during restart is
662
+ // reported; the previous node will already be stopped, so the caller
663
+ // is in a known-disconnected state and can retry.
664
+ try {
665
+ await node.stop();
666
+ } catch (e) {
667
+ return {
668
+ content: [{ type: 'text', text: `Failed to stop current node: ${e?.message || e}` }],
669
+ isError: true,
670
+ };
671
+ }
672
+
673
+ const newNode = new SymNode({
674
+ name: NODE_NAME,
675
+ cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
676
+ svafFieldWeights: FIELD_WEIGHTS,
677
+ svafFreshnessSeconds: 7200,
678
+ discoveryServiceType: newServiceType,
679
+ group,
680
+ relay: relayUrl,
681
+ relayToken,
682
+ silent: true,
683
+ });
684
+ registerNodeHandlers(newNode);
685
+
686
+ try {
687
+ await newNode.start();
688
+ } catch (e) {
689
+ return {
690
+ content: [{
691
+ type: 'text',
692
+ text: `Failed to start new node on group "${group}": ${e?.message || e}\n\n` +
693
+ `Previous node already stopped. To recover, call sym_join_group with group="${prevGroup}".`,
694
+ }],
695
+ isError: true,
696
+ };
697
+ }
698
+
699
+ // Swap module-level references only after successful start.
700
+ node = newNode;
701
+ GROUP = group;
702
+ SERVICE_TYPE = newServiceType;
703
+ RELAY_URL = relayUrl;
704
+ RELAY_TOKEN = relayToken;
705
+
706
+ return {
707
+ content: [{
708
+ type: 'text',
709
+ text: `Hot-swapped from group "${prevGroup}" (${prevServiceType}) to "${group}" (${newServiceType}).\n` +
710
+ (relayUrl ? `Relay: ${relayUrl}\n` : '') +
711
+ `Discovering peers on the new service type. Call sym_peers in a moment to see who's online.`,
712
+ }],
713
+ };
714
+ }
715
+
716
+ case 'sym_groups_discover': {
717
+ const result = await discoverGroups();
718
+ return {
719
+ content: [{
720
+ type: 'text',
721
+ text: result.text,
722
+ }],
723
+ isError: result.isError || false,
724
+ };
725
+ }
726
+
341
727
  default:
342
728
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
343
729
  }
@@ -416,30 +802,10 @@ function pushChannel(eventType, data) {
416
802
  } catch {}
417
803
  }
418
804
 
419
- node.on('cmb-accepted', (entry) => {
420
- // Don't echo back our own CMBs
421
- if (entry.source === NODE_NAME || entry.cmb?.createdBy === NODE_NAME) return;
422
-
423
- const source = entry.source || entry.cmb?.createdBy || 'unknown';
424
-
425
- // Peer allowlist gate (defense-in-depth, see SECURITY.md)
426
- if (!isPeerAllowed(source)) return;
427
-
428
- const focus = entry.cmb?.fields?.focus?.text || entry.content || '';
429
- const mood = entry.cmb?.fields?.mood?.text || '';
430
- pushChannel('cmb', `[${source}] ${focus}${mood && mood !== 'neutral' ? ` (mood: ${mood})` : ''}`);
431
- });
432
-
433
- node.on('message', (from, content) => {
434
- // Peer allowlist gate
435
- if (!isPeerAllowed(from)) return;
436
-
437
- // Compact channel: store full content, push only header + msg_id.
438
- // Agent calls sym_fetch(msg_id) for full content when needed.
439
- const msgId = storeMessage(from, content);
440
- const header = extractCompactHeader(from, content);
441
- pushChannel('message', `[${from}] ${header} [${msgId}]`);
442
- });
805
+ // All node.on(...) handlers live in registerNodeHandlers(n) above so the
806
+ // hot-swap path in sym_join_group can attach them to a freshly-constructed
807
+ // SymNode without duplicating logic. This call wires up the initial node.
808
+ registerNodeHandlers(node);
443
809
 
444
810
  // Peer presence events are intentionally NOT pushed to Claude's context.
445
811
  // They're high-frequency, low-signal (peers flap on relay reconnects, daemon