@sym-bot/mesh-channel 0.1.22 → 0.1.23
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +87 -2
- package/package.json +1 -1
- package/server.js +364 -65
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Real-time Claude-to-Claude mesh. Peer-to-peer cognitive signals over Bonjour LAN or WebSocket relay.",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.23"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "sym-mesh-channel",
|
|
14
14
|
"source": "./",
|
|
15
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.
|
|
16
|
+
"version": "0.1.23",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Hongwei Xu",
|
|
19
19
|
"email": "hongwei@sym.bot"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`sym_join_group(group, relay_url?, relay_token?)`** — hot-swap this
|
|
8
|
+
node into a different mesh group at runtime, no Claude Code restart.
|
|
9
|
+
Stops the current SymNode, reconstructs it on the new service type
|
|
10
|
+
(and optional relay), re-registers event handlers, restarts. The
|
|
11
|
+
"smooth way to join" that was missing in 0.1.22.
|
|
12
|
+
|
|
13
|
+
- **`sym_invite_create(group, relay_url?, relay_token?)`** — generate
|
|
14
|
+
a shareable invite URL for a named group. Two flavors:
|
|
15
|
+
- LAN-only: `sym://group/{name}` (Bonjour isolation only)
|
|
16
|
+
- Cross-network: `sym://team/{name}?relay=...&token=...` (routes via
|
|
17
|
+
a WebSocket relay so teammates on different networks can join).
|
|
18
|
+
Validates kebab-case group names, rejects token without URL.
|
|
19
|
+
|
|
20
|
+
- **`sym_invite_info(url)`** extended to parse the new `sym://team/`
|
|
21
|
+
path and the `relay=` + `token=` query-string parameters.
|
|
22
|
+
Output now includes a ready-to-paste `sym_join_group` call as JSON.
|
|
23
|
+
|
|
24
|
+
- **`sym_groups_discover()`** — enumerate SYM-mesh groups currently
|
|
25
|
+
advertising on the local LAN via Bonjour / mDNS. Shell-outs to
|
|
26
|
+
`dns-sd` (macOS/Windows) or `avahi-browse` (Linux) with a 2-second
|
|
27
|
+
timeout, filters to service types matching the SYM protocol family
|
|
28
|
+
(global `_sym._tcp`, named groups, `{app}-{id}` rooms). Peer-to-peer
|
|
29
|
+
means only groups with live members right now are visible — no
|
|
30
|
+
central directory.
|
|
31
|
+
|
|
32
|
+
- **README — "Dev-team groups" walkthrough** with two concrete scenarios:
|
|
33
|
+
LAN dev-team group (single office) and cross-network team group via
|
|
34
|
+
the public `wss://sym-relay.onrender.com` relay. Shows exact tool
|
|
35
|
+
calls from both the team lead and each teammate.
|
|
36
|
+
|
|
37
|
+
- **13 new tests** covering invite URL parse, generate, round-trip, and
|
|
38
|
+
validation (kebab-case, token-requires-URL guard). Test suite now at
|
|
39
|
+
35 tests total.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Module-level `node`, `GROUP`, `SERVICE_TYPE`, `RELAY_URL`,
|
|
44
|
+
`RELAY_TOKEN` declared as `let` (was `const`) so the hot-swap path
|
|
45
|
+
can re-bind them. All node event handlers (`identity-collision`,
|
|
46
|
+
`cmb-accepted`, `message`) extracted into a single
|
|
47
|
+
`registerNodeHandlers(n)` function so the hot-swap path re-attaches
|
|
48
|
+
them without duplicating logic.
|
|
49
|
+
|
|
50
|
+
- Tool count in README corrected to 11 (was 8 in 0.1.22):
|
|
51
|
+
+ sym_invite_create, sym_join_group, sym_groups_discover.
|
|
52
|
+
|
|
3
53
|
## 0.1.22
|
|
4
54
|
|
|
5
55
|
### Added
|
package/README.md
CHANGED
|
@@ -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 (peer-to-peer 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
|
-
|
|
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
|
-
| `
|
|
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
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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:
|
|
81
|
-
relayToken:
|
|
213
|
+
relay: RELAY_URL,
|
|
214
|
+
relayToken: RELAY_TOKEN,
|
|
82
215
|
silent: true,
|
|
83
216
|
});
|
|
84
217
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
|
@@ -184,15 +340,46 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
184
340
|
description: 'Report the mesh group this node is in (MMP §5.8). Shows service type + group name + peer count.',
|
|
185
341
|
inputSchema: { type: 'object', properties: {} },
|
|
186
342
|
},
|
|
343
|
+
{
|
|
344
|
+
name: 'sym_invite_create',
|
|
345
|
+
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.',
|
|
346
|
+
inputSchema: {
|
|
347
|
+
type: 'object',
|
|
348
|
+
properties: {
|
|
349
|
+
group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team".' },
|
|
350
|
+
relay_url: { type: 'string', description: 'Optional WebSocket relay URL, e.g. wss://sym-relay.onrender.com. Include for cross-network teams.' },
|
|
351
|
+
relay_token: { type: 'string', description: 'Optional relay authentication token (shared secret for this team channel).' },
|
|
352
|
+
},
|
|
353
|
+
required: ['group'],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
187
356
|
{
|
|
188
357
|
name: 'sym_invite_info',
|
|
189
|
-
description: '
|
|
358
|
+
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
359
|
inputSchema: {
|
|
191
360
|
type: 'object',
|
|
192
|
-
properties: { url: { type: 'string', description: 'Invite URL, e.g.
|
|
361
|
+
properties: { url: { type: 'string', description: 'Invite URL, e.g. sym://group/backend-team' } },
|
|
193
362
|
required: ['url'],
|
|
194
363
|
},
|
|
195
364
|
},
|
|
365
|
+
{
|
|
366
|
+
name: 'sym_join_group',
|
|
367
|
+
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.',
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team". Pass "default" to return to the global mesh.' },
|
|
372
|
+
relay_url: { type: 'string', description: 'Optional WebSocket relay URL for cross-network teams. Leave empty for LAN-only.' },
|
|
373
|
+
relay_token: { type: 'string', description: 'Optional relay authentication token.' },
|
|
374
|
+
},
|
|
375
|
+
required: ['group'],
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: 'sym_groups_discover',
|
|
380
|
+
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.',
|
|
381
|
+
inputSchema: { type: 'object', properties: {} },
|
|
382
|
+
},
|
|
196
383
|
],
|
|
197
384
|
}));
|
|
198
385
|
|
|
@@ -303,41 +490,173 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
303
490
|
};
|
|
304
491
|
}
|
|
305
492
|
|
|
493
|
+
case 'sym_invite_create': {
|
|
494
|
+
const group = args?.group;
|
|
495
|
+
const relayUrl = args?.relay_url;
|
|
496
|
+
const relayToken = args?.relay_token;
|
|
497
|
+
if (!group || typeof group !== 'string') {
|
|
498
|
+
return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
|
|
499
|
+
}
|
|
500
|
+
if (!KEBAB_CASE_RE.test(group)) {
|
|
501
|
+
return {
|
|
502
|
+
content: [{
|
|
503
|
+
type: 'text',
|
|
504
|
+
text: `Invalid group name: "${group}". Must be kebab-case (lowercase alphanumerics + single hyphens), e.g. "backend-team".`,
|
|
505
|
+
}],
|
|
506
|
+
isError: true,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// LAN-only flavor: sym://group/{name}
|
|
510
|
+
// Cross-network flavor: sym://team/{name}?relay=...&token=...
|
|
511
|
+
let url;
|
|
512
|
+
let flavor;
|
|
513
|
+
if (relayUrl || relayToken) {
|
|
514
|
+
if (!relayUrl) return { content: [{ type: 'text', text: 'relay_token requires relay_url' }], isError: true };
|
|
515
|
+
const params = [`relay=${encodeURIComponent(relayUrl)}`];
|
|
516
|
+
if (relayToken) params.push(`token=${encodeURIComponent(relayToken)}`);
|
|
517
|
+
url = `sym://team/${group}?${params.join('&')}`;
|
|
518
|
+
flavor = 'cross-network (relay)';
|
|
519
|
+
} else {
|
|
520
|
+
url = `sym://group/${group}`;
|
|
521
|
+
flavor = 'LAN-only (Bonjour)';
|
|
522
|
+
}
|
|
523
|
+
const youRunning = GROUP === group
|
|
524
|
+
? `You're already on this group — teammates who join will see you.`
|
|
525
|
+
: `You are currently on group "${GROUP}". To be reachable, call sym_join_group with group="${group}" (+ same relay creds if cross-network) before sharing.`;
|
|
526
|
+
return {
|
|
527
|
+
content: [{
|
|
528
|
+
type: 'text',
|
|
529
|
+
text: `Invite URL (${flavor}):\n\n ${url}\n\n` +
|
|
530
|
+
`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` +
|
|
531
|
+
youRunning,
|
|
532
|
+
}],
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
306
536
|
case 'sym_invite_info': {
|
|
307
537
|
const url = args?.url;
|
|
308
538
|
if (!url || typeof url !== 'string') {
|
|
309
539
|
return { content: [{ type: 'text', text: 'Missing required argument: url' }], isError: true };
|
|
310
540
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 };
|
|
541
|
+
const parsed = parseInviteURL(url);
|
|
542
|
+
if (parsed.error) {
|
|
543
|
+
return { content: [{ type: 'text', text: parsed.error }], isError: true };
|
|
317
544
|
}
|
|
318
|
-
const appScheme =
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
:
|
|
325
|
-
|
|
545
|
+
const { appScheme, group, serviceType, roomId, roomName, relayUrl, relayToken } = parsed;
|
|
546
|
+
|
|
547
|
+
const out = {
|
|
548
|
+
app: appScheme,
|
|
549
|
+
group,
|
|
550
|
+
service_type: serviceType,
|
|
551
|
+
room_id: appScheme === 'sym' ? undefined : roomId,
|
|
552
|
+
room_name: appScheme === 'sym' ? undefined : roomName,
|
|
553
|
+
relay_url: relayUrl || undefined,
|
|
554
|
+
relay_token: relayToken || undefined,
|
|
555
|
+
};
|
|
556
|
+
for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
|
|
557
|
+
|
|
558
|
+
const joinCall = {
|
|
559
|
+
group,
|
|
560
|
+
...(relayUrl && { relay_url: relayUrl }),
|
|
561
|
+
...(relayToken && { relay_token: relayToken }),
|
|
562
|
+
};
|
|
563
|
+
|
|
326
564
|
return {
|
|
327
565
|
content: [{
|
|
328
566
|
type: 'text',
|
|
329
|
-
text:
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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),
|
|
567
|
+
text: `Parsed invite: ${url}\n\n` +
|
|
568
|
+
JSON.stringify(out, null, 2) + `\n\n` +
|
|
569
|
+
`To join, call sym_join_group:\n\n ${JSON.stringify(joinCall)}\n\n` +
|
|
570
|
+
`This hot-swaps your node into the ${relayUrl ? 'relay channel' : 'LAN group'} — no Claude Code restart needed.`,
|
|
337
571
|
}],
|
|
338
572
|
};
|
|
339
573
|
}
|
|
340
574
|
|
|
575
|
+
case 'sym_join_group': {
|
|
576
|
+
const group = args?.group;
|
|
577
|
+
const relayUrl = args?.relay_url || null;
|
|
578
|
+
const relayToken = args?.relay_token || null;
|
|
579
|
+
if (!group || typeof group !== 'string') {
|
|
580
|
+
return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
|
|
581
|
+
}
|
|
582
|
+
if (!KEBAB_CASE_RE.test(group) && group !== 'default') {
|
|
583
|
+
return {
|
|
584
|
+
content: [{ type: 'text', text: `Invalid group name: "${group}". Must be kebab-case or "default".` }],
|
|
585
|
+
isError: true,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const newServiceType = group === 'default' ? '_sym._tcp' : `_${group}._tcp`;
|
|
590
|
+
const prevGroup = GROUP;
|
|
591
|
+
const prevServiceType = SERVICE_TYPE;
|
|
592
|
+
|
|
593
|
+
// Stop the current node cleanly so peers see us leave, then construct
|
|
594
|
+
// a fresh one on the new service type. Any failure during restart is
|
|
595
|
+
// reported; the previous node will already be stopped, so the caller
|
|
596
|
+
// is in a known-disconnected state and can retry.
|
|
597
|
+
try {
|
|
598
|
+
await node.stop();
|
|
599
|
+
} catch (e) {
|
|
600
|
+
return {
|
|
601
|
+
content: [{ type: 'text', text: `Failed to stop current node: ${e?.message || e}` }],
|
|
602
|
+
isError: true,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const newNode = new SymNode({
|
|
607
|
+
name: NODE_NAME,
|
|
608
|
+
cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
|
|
609
|
+
svafFieldWeights: FIELD_WEIGHTS,
|
|
610
|
+
svafFreshnessSeconds: 7200,
|
|
611
|
+
discoveryServiceType: newServiceType,
|
|
612
|
+
group,
|
|
613
|
+
relay: relayUrl,
|
|
614
|
+
relayToken,
|
|
615
|
+
silent: true,
|
|
616
|
+
});
|
|
617
|
+
registerNodeHandlers(newNode);
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
await newNode.start();
|
|
621
|
+
} catch (e) {
|
|
622
|
+
return {
|
|
623
|
+
content: [{
|
|
624
|
+
type: 'text',
|
|
625
|
+
text: `Failed to start new node on group "${group}": ${e?.message || e}\n\n` +
|
|
626
|
+
`Previous node already stopped. To recover, call sym_join_group with group="${prevGroup}".`,
|
|
627
|
+
}],
|
|
628
|
+
isError: true,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Swap module-level references only after successful start.
|
|
633
|
+
node = newNode;
|
|
634
|
+
GROUP = group;
|
|
635
|
+
SERVICE_TYPE = newServiceType;
|
|
636
|
+
RELAY_URL = relayUrl;
|
|
637
|
+
RELAY_TOKEN = relayToken;
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
content: [{
|
|
641
|
+
type: 'text',
|
|
642
|
+
text: `Hot-swapped from group "${prevGroup}" (${prevServiceType}) to "${group}" (${newServiceType}).\n` +
|
|
643
|
+
(relayUrl ? `Relay: ${relayUrl}\n` : '') +
|
|
644
|
+
`Discovering peers on the new service type. Call sym_peers in a moment to see who's online.`,
|
|
645
|
+
}],
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
case 'sym_groups_discover': {
|
|
650
|
+
const result = await discoverGroups();
|
|
651
|
+
return {
|
|
652
|
+
content: [{
|
|
653
|
+
type: 'text',
|
|
654
|
+
text: result.text,
|
|
655
|
+
}],
|
|
656
|
+
isError: result.isError || false,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
341
660
|
default:
|
|
342
661
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
343
662
|
}
|
|
@@ -416,30 +735,10 @@ function pushChannel(eventType, data) {
|
|
|
416
735
|
} catch {}
|
|
417
736
|
}
|
|
418
737
|
|
|
419
|
-
node.on(
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
});
|
|
738
|
+
// All node.on(...) handlers live in registerNodeHandlers(n) above so the
|
|
739
|
+
// hot-swap path in sym_join_group can attach them to a freshly-constructed
|
|
740
|
+
// SymNode without duplicating logic. This call wires up the initial node.
|
|
741
|
+
registerNodeHandlers(node);
|
|
443
742
|
|
|
444
743
|
// Peer presence events are intentionally NOT pushed to Claude's context.
|
|
445
744
|
// They're high-frequency, low-signal (peers flap on relay reconnects, daemon
|