@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +83 -0
- package/README.md +88 -3
- package/package.json +2 -2
- package/server.js +453 -87
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
"email": "info@sym.bot"
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
|
-
"description": "Real-time Claude-to-Claude mesh.
|
|
9
|
-
"version": "0.
|
|
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.
|
|
16
|
-
"version": "0.
|
|
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.
|
|
4
|
-
"description": "Real-time Claude-to-Claude mesh.
|
|
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
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sym-bot/mesh-channel",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
111
|
-
'Share observations about
|
|
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:
|
|
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: {
|
|
127
|
-
|
|
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:
|
|
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: '
|
|
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.
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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 };
|
|
608
|
+
const parsed = parseInviteURL(url);
|
|
609
|
+
if (parsed.error) {
|
|
610
|
+
return { content: [{ type: 'text', text: parsed.error }], isError: true };
|
|
317
611
|
}
|
|
318
|
-
const appScheme =
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
:
|
|
325
|
-
|
|
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:
|
|
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),
|
|
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(
|
|
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
|
-
});
|
|
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
|