@sym-bot/mesh-channel 0.3.11 → 0.3.12

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/server.js CHANGED
@@ -1,1105 +1,1105 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // Subcommand dispatch: `sym-mesh-channel init` runs the installer.
5
- if (process.argv[2] === 'init') {
6
- require('./bin/install.js');
7
- return;
8
- }
9
-
10
- // ── stdout discipline (v0.3.9) ──────────────────────────────────────────────
11
- // MCP frames JSON-RPC on stdout. Any non-JSON write there — ours or, far more
12
- // often, a dependency's load banner (e.g. "[encoder] Semantic encoder ready"
13
- // from the semantic model) — corrupts the stream and makes Claude Code drop the
14
- // connection (-32000) or log "Ignoring non-JSON line on stdout". Guard it: lines
15
- // that look like JSON-RPC (start with '{') pass through to the real stdout;
16
- // everything else is redirected to stderr. Installed before any require so it
17
- // catches dependency output at load time.
18
- const __realStdoutWrite = process.stdout.write.bind(process.stdout);
19
- process.stdout.write = function (chunk, ...rest) {
20
- try {
21
- const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
22
- if (s.trimStart().startsWith('{')) return __realStdoutWrite(chunk, ...rest);
23
- return process.stderr.write(chunk, ...rest);
24
- } catch {
25
- return __realStdoutWrite(chunk, ...rest);
26
- }
27
- };
28
-
29
- /**
30
- * sym-mesh-channel — MCP server that makes Claude Code a peer node on the SYM mesh.
31
- *
32
- * Architecture (MMP Section 13.9: Local Event Interface):
33
- * SymNode (own identity, own SVAF field weights) → relay → mesh
34
- * MCP channel notifications → Claude Code (real-time push)
35
- * MCP tools → SymNode methods (send, observe, recall)
36
- *
37
- * This is a PEER NODE, not a client of the daemon. It has its own identity,
38
- * its own relay connection, and its own SVAF evaluation with engineering-domain
39
- * field weights. Per MMP Section 3: every participant is a peer.
40
- *
41
- * Copyright (c) 2026 SYM.BOT. Apache 2.0 License.
42
- */
43
-
44
- const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
45
- const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
46
- const {
47
- CallToolRequestSchema,
48
- ListToolsRequestSchema,
49
- } = require('@modelcontextprotocol/sdk/types.js');
50
- const { SymNode } = require('@sym-bot/sym');
51
-
52
- // Kebab-case validator shared by group-related tools.
53
- const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
54
-
55
- // ── Invite URL parsing (shared by sym_invite_info and the internal
56
- // validation path for sym_join_group when passed a URL). Exposed as
57
- // a module-level function so it's trivially unit-testable and the
58
- // same regex doesn't drift between two call sites.
59
-
60
- const INVITE_URL_RE = /^([a-z][a-z0-9-]+):\/\/(?:room|group|team)\/([^/?#]+)(?:\/([^?#]+))?(?:\?(.+))?$/i;
61
-
62
- function parseInviteURL(url) {
63
- const m = INVITE_URL_RE.exec(url);
64
- if (!m) {
65
- return {
66
- error:
67
- `Unrecognised invite URL: ${url}\n\n` +
68
- `Expected shapes:\n` +
69
- ` sym://group/{name} (LAN-only)\n` +
70
- ` sym://team/{name}?relay=...&token=... (cross-network via relay)\n` +
71
- ` melotune://room/{id}/{name} (app-specific room)`,
72
- };
73
- }
74
- const appScheme = m[1].toLowerCase();
75
- const rawId = decodeURIComponent(m[2]);
76
- const rawName = m[3] ? decodeURIComponent(m[3]) : rawId;
77
- const queryStr = m[4] || '';
78
- const query = Object.fromEntries(
79
- queryStr.split('&').filter(Boolean).map(kv => {
80
- const [k, v = ''] = kv.split('=');
81
- return [decodeURIComponent(k), decodeURIComponent(v)];
82
- })
83
- );
84
- // For sym:// the path element IS the group name. For app-scoped URLs
85
- // (melotune://, melomove://, etc.) the path is the room id and the
86
- // group is prefixed with the app name to avoid collisions.
87
- const serviceType = appScheme === 'sym' ? `_${rawId}._tcp` : `_${appScheme}-${rawId}._tcp`;
88
- const group = appScheme === 'sym' ? rawId : `${appScheme}-${rawId}`;
89
- return {
90
- appScheme,
91
- group,
92
- serviceType,
93
- roomId: rawId,
94
- roomName: rawName,
95
- relayUrl: query.relay || null,
96
- relayToken: query.token || null,
97
- };
98
- }
99
-
100
- // ── Bonjour discovery of live SYM-related service types.
101
- // Runs `dns-sd -B _services._dns-sd._udp local.` (macOS / Windows with
102
- // Bonjour) or `avahi-browse -at` (Linux) for 2 seconds, filters to
103
- // service types that look SYM-ish, and reports them. Pure observation,
104
- // no node state changes.
105
-
106
- async function discoverGroups() {
107
- const { spawn } = require('child_process');
108
- const platform = process.platform;
109
-
110
- let cmd, argv;
111
- if (platform === 'darwin' || platform === 'win32') {
112
- cmd = 'dns-sd';
113
- argv = ['-B', '_services._dns-sd._udp', 'local.'];
114
- } else {
115
- cmd = 'avahi-browse';
116
- argv = ['-t', '-a', '-p']; // terminate after cache, all services, parseable
117
- }
118
-
119
- return new Promise((resolve) => {
120
- let child;
121
- try {
122
- child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
123
- } catch (e) {
124
- return resolve({
125
- isError: true,
126
- text:
127
- `Could not run discovery command '${cmd}': ${e?.message || e}\n\n` +
128
- (platform === 'linux'
129
- ? `On Linux, install avahi-utils: sudo apt install avahi-utils`
130
- : `Bonjour should be built-in on macOS and Windows 10+.`),
131
- });
132
- }
133
- const out = [];
134
- child.stdout.on('data', (chunk) => out.push(chunk));
135
- child.on('error', (e) => resolve({ isError: true, text: `Discovery command failed: ${e?.message || e}` }));
136
-
137
- const timer = setTimeout(() => {
138
- try { child.kill('SIGTERM'); } catch {}
139
- }, 2000);
140
- child.on('close', () => {
141
- clearTimeout(timer);
142
- const text = Buffer.concat(out).toString('utf8');
143
- const typeRe = /_([a-z0-9][a-z0-9-]+)\._tcp/gi;
144
- const seen = new Set();
145
- let m;
146
- while ((m = typeRe.exec(text)) !== null) {
147
- const full = `_${m[1]}._tcp`;
148
- // Filter to the SYM protocol family: global sym, named groups, and
149
- // app-scoped rooms (melotune-<id>, melomove-<id>, etc). Anything
150
- // that looks like generic infra (_services._dns-sd, _tcp, _udp,
151
- // printer protocols, etc.) is ignored.
152
- if (/^_(sym|[a-z]+-[a-z0-9]+|[a-z]+-team|.*-team)\._tcp$/i.test(full)) {
153
- seen.add(full);
154
- }
155
- }
156
- if (seen.size === 0) {
157
- return resolve({
158
- text:
159
- `No SYM-mesh groups visible on the local network right now.\n\n` +
160
- `This only shows groups with at least one node currently online. ` +
161
- `Groups you or teammates have used before are not persisted anywhere ` +
162
- `(p2p architecture — no central directory).\n\n` +
163
- `Your node is on: ${SERVICE_TYPE} (group "${GROUP}").`,
164
- });
165
- }
166
- const lines = [];
167
- lines.push(`SYM-mesh groups visible on LAN (${seen.size}):`);
168
- for (const st of Array.from(seen).sort()) {
169
- const name = st.replace(/^_/, '').replace(/\._tcp$/, '');
170
- const isSelf = st === SERVICE_TYPE ? ' (← your current group)' : '';
171
- lines.push(` ${st} group="${name}"${isSelf}`);
172
- }
173
- lines.push('');
174
- lines.push(`To join one, call sym_join_group with group="<name>".`);
175
- resolve({ text: lines.join('\n') });
176
- });
177
- });
178
- }
179
-
180
- // ── Engineering-domain field weights (SVAF α_f) ──────────────
181
-
182
- const FIELD_WEIGHTS = {
183
- focus: 2.0, // code, architecture, technical decisions
184
- issue: 2.0, // bugs, blockers, technical debt
185
- intent: 1.5, // what needs building
186
- motivation: 1.0, // why it matters
187
- commitment: 1.5, // deadlines, dependencies
188
- perspective: 0.5, // viewpoint — low for engineering
189
- mood: 0.8, // user fatigue affects code quality
190
- };
191
-
192
- // ── SymNode — full peer on the mesh ──────────────────────────
193
-
194
- // Default: hostname-based identity, unique per machine. The old default
195
- // ('claude-code-mac') caused ghost-peer bugs when another machine ran
196
- // without SYM_NODE_NAME set — both machines claimed the same name with
197
- // different nodeIds, creating phantom peers that absorbed messages.
198
- // Per-session default (v0.3.8): keep co-resident Claude Code sessions from all
199
- // claiming one shared identity and colliding on the identity lock. Each Claude
200
- // Code session exposes CLAUDE_CODE_SESSION_ID (stable across `--resume`) and
201
- // CLAUDE_PROJECT_DIR, so the default becomes `claude-<repo>-<session6>` —
202
- // unique even for two sessions in the same repo, readable, and stable across
203
- // resume. Bare-npm use (no session id) keeps the hostname default. Named agents
204
- // override with SYM_NODE_NAME (e.g. claude-code-mac, melotune-dev).
205
- function defaultNodeName() {
206
- const clean = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '');
207
- const sid = clean(process.env.CLAUDE_CODE_SESSION_ID).slice(0, 6);
208
- if (sid) {
209
- const repo = clean(require('path').basename(process.env.CLAUDE_PROJECT_DIR || process.cwd())) || 'session';
210
- return `claude-${repo}-${sid}`;
211
- }
212
- return `claude-${clean(require('os').hostname())}`;
213
- }
214
- // Live-collision auto-suffix (v0.3.10): @sym-bot/sym already reclaims STALE locks
215
- // (dead holder), so crashed sessions self-heal. But two LIVE sessions wanting the
216
- // same name — a duplicate dev agent, or two sessions sharing a fixed SYM_NODE_NAME
217
- // — would hard-fail with EIDENTITYLOCK. Resolve the name up front: if the base is
218
- // held by a live process, append -2/-3/… so the second session coexists instead of
219
- // failing. A dead or absent holder keeps the base name (sym reclaims it on start).
220
- function resolveNodeName(base) {
221
- const fs = require('fs'), os = require('os'), path = require('path');
222
- const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
223
- for (let i = 0; i < 64; i++) {
224
- const name = i === 0 ? base : `${base}-${i + 1}`;
225
- try {
226
- const pid = parseInt(fs.readFileSync(path.join(os.homedir(), '.sym', 'nodes', name, 'lock.pid'), 'utf8').trim(), 10);
227
- if (pid && alive(pid)) continue; // live holder → try the next suffix
228
- } catch { /* no lock file → name is free */ }
229
- return name; // free, or a stale lock sym will reclaim on start()
230
- }
231
- return base;
232
- }
233
- const NODE_NAME = resolveNodeName(process.env.SYM_NODE_NAME || defaultNodeName());
234
-
235
- // ── Mesh group (MMP §5.8) ──────────────────────────────────
236
- //
237
- // LAN isolation by Bonjour service type. `_sym._tcp` is the default
238
- // (backward compatible). A named group `<foo>` maps to service type
239
- // `_foo._tcp`. Passing a full `_foo._tcp` service type explicitly also
240
- // works. Nodes in different groups never discover each other at mDNS.
241
- // See MeloTune's MoodRoom model for the per-room pattern
242
- // (`_melotune-{id}._tcp`).
243
- function resolveServiceType() {
244
- const explicit = process.env.SYM_SERVICE_TYPE;
245
- if (explicit) return explicit;
246
- const group = process.env.SYM_GROUP;
247
- if (group && group !== 'default') return `_${group}._tcp`;
248
- return '_sym._tcp';
249
- }
250
- // Mutable so sym_join_group can hot-swap the node at runtime without a
251
- // Claude Code restart. Declaring as `let` rather than `const` is the
252
- // smallest change that makes hot-swap possible.
253
- let SERVICE_TYPE = resolveServiceType();
254
- let GROUP = process.env.SYM_GROUP || (SERVICE_TYPE !== '_sym._tcp'
255
- ? SERVICE_TYPE.replace(/^_/, '').replace(/\._tcp$/, '')
256
- : 'default');
257
- let RELAY_URL = process.env.SYM_RELAY_URL || null;
258
- let RELAY_TOKEN = process.env.SYM_RELAY_TOKEN || null;
259
-
260
- let node = new SymNode({
261
- name: NODE_NAME,
262
- cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
263
- svafFieldWeights: FIELD_WEIGHTS,
264
- svafFreshnessSeconds: 7200, // 2hr — session-length context
265
- discoveryServiceType: SERVICE_TYPE,
266
- group: GROUP,
267
- relay: RELAY_URL,
268
- relayToken: RELAY_TOKEN,
269
- silent: true,
270
- });
271
-
272
- // Event handlers are extracted into a single registration function so the
273
- // hot-swap path in sym_join_group can re-register them on the new node.
274
- // The function reads module-level `NODE_NAME`, `isPeerAllowed`, `pushChannel`,
275
- // `storeMessage`, and `extractCompactHeader` via closure; those don't change
276
- // across swaps.
277
- function registerNodeHandlers(n) {
278
- // Identity collision (added in @sym-bot/sym 0.3.68): the relay told us
279
- // another process is holding our nodeId. Don't try to reconnect — that
280
- // caused the peer-flap loop documented in v0.1.2/v0.1.3 commit messages.
281
- // Exit so Claude Code can decide whether to respawn (with the freshness
282
- // window now elapsed) or surface the failure to the user.
283
- n.on('identity-collision', (info) => {
284
- process.stderr.write(
285
- `sym-mesh-channel: identity collision on relay — another process is holding ` +
286
- `nodeId=${info.nodeId} name=${info.name}. Exiting.\n`
287
- );
288
- process.exit(2);
289
- });
290
-
291
- n.on('cmb-accepted', (entry) => {
292
- if (entry.source === NODE_NAME || entry.cmb?.createdBy === NODE_NAME) return;
293
- const source = entry.source || entry.cmb?.createdBy || 'unknown';
294
- if (!isPeerAllowed(source)) return;
295
- const fields = entry.cmb?.fields || {};
296
- const payload = entry.cmb?.payload;
297
- const sec = checkSecurity(source, fields, payload);
298
- if (!sec.safe) { securityAudit(sec.reason, source, sec.excerpt); return; }
299
- const focus = fields?.focus?.text || entry.content || '';
300
- const mood = fields?.mood?.text || '';
301
- const moodSuffix = mood && mood !== 'neutral' ? ` (mood: ${mood})` : '';
302
- // Store the rendered CMB body so the agent can sym_fetch it by [mNNN] ID.
303
- // When the CMB carries an opaque payload alongside CAT7 fields, append a
304
- // PAYLOAD section to the stored body so sym_fetch returns it intact;
305
- // header gains a [+payload Nb] indicator so the receiver knows there's
306
- // structured data beyond CAT7 and should sym_fetch to consume it.
307
- const hasPayload = payload !== undefined && payload !== null;
308
- let body = entry.content || focus;
309
- let payloadSuffix = '';
310
- if (hasPayload) {
311
- const serialized = (() => {
312
- try { return JSON.stringify(payload, null, 2); }
313
- catch { return String(payload); }
314
- })();
315
- body = `${body}\n\n---PAYLOAD---\n${serialized}`;
316
- payloadSuffix = ` [+payload ${serialized.length}b]`;
317
- }
318
- const msgId = storeMessage(source, body);
319
- pushChannel('cmb', `[${source}] ${focus}${moodSuffix}${payloadSuffix} [${msgId}]`);
320
- });
321
-
322
- n.on('message', (from, content) => {
323
- if (!isPeerAllowed(from)) return;
324
- const sec = checkSecurity(from, { focus: { text: content } }, null);
325
- if (!sec.safe) { securityAudit(sec.reason, from, sec.excerpt); return; }
326
- const msgId = storeMessage(from, content);
327
- const header = extractCompactHeader(from, content);
328
- pushChannel('message', `[${from}] ${header} [${msgId}]`);
329
- });
330
- }
331
-
332
- // ── MCP Server ───────────────────────────────────────────────
333
-
334
- // Base instructions shown to the agent at every MCP initialize.
335
- const BASE_INSTRUCTIONS =
336
- `You are a peer node on the SYM mesh (identity: ${NODE_NAME}). ` +
337
- 'Mesh events arrive as <channel> notifications in real-time. ' +
338
- '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). ' +
339
- 'Share observations about your own state with the whole mesh via sym_observe (MMP §9.2 receiver-autonomous SVAF evaluation). ' +
340
- 'Both sym_send and sym_observe emit CAT7 CMBs; receivers run SVAF and, if admitted, remix-store with lineage pointing back to your CMB. ' +
341
- 'Search mesh memory via sym_recall. ' +
342
- '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.';
343
-
344
- // Final startup step (MMP §4.2 O2 — rejoin-without-replay). The SymNode
345
- // constructor builds the memory-store index from disk, so the primer is
346
- // available synchronously without needing node.start(). Appending it to
347
- // the MCP instructions payload means a fresh Claude Code session wakes
348
- // with prior remix memory — own observations plus peer observations
349
- // admitted by SVAF — already loaded into context, zero first-turn
350
- // sym_recall overhead.
351
- //
352
- // MCP SDK reads `instructions` at Server construction time (storing it in
353
- // a private field) and emits it only on initialize-response; mutations on
354
- // the public property after construction are ignored. Compute once, pass in.
355
- let primerText = '';
356
- try {
357
- const primer = node.buildStartupPrimer();
358
- if (primer && primer.count > 0) primerText = `\n\n${primer.text}`;
359
- } catch (err) {
360
- process.stderr.write(`sym-mesh-channel startup primer skipped: ${err?.message || err}\n`);
361
- }
362
-
363
- const mcp = new Server(
364
- { name: 'sym-mesh', version: '0.1.0' },
365
- {
366
- capabilities: {
367
- tools: {},
368
- experimental: { 'claude/channel': {} },
369
- },
370
- instructions: BASE_INSTRUCTIONS + primerText,
371
- },
372
- );
373
-
374
- // ── Tools ────────────────────────────────────────────────────
375
-
376
- mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
377
- tools: [
378
- {
379
- name: 'sym_send',
380
- description:
381
- 'Send a structured CAT7 CMB to a specific mesh peer (targeted) or to all peers (broadcast, when "to" is omitted). ' +
382
- 'Receivers evaluate the CMB per-field via SVAF (MMP §9.2) and, if admitted, remix-store it with lineage pointing back to this CMB. ' +
383
- 'Use sym_send when the CMB is for a specific peer (e.g. a peer-review gating request directed at the reviewer role); ' +
384
- 'use sym_observe when sharing your own state with the whole mesh.',
385
- inputSchema: {
386
- type: 'object',
387
- properties: {
388
- focus: { type: 'string', description: 'The task anchor / what this CMB is about. Required.' },
389
- issue: { type: 'string' },
390
- intent: { type: 'string' },
391
- motivation: { type: 'string' },
392
- commitment: { type: 'string' },
393
- perspective: { type: 'string' },
394
- mood: {
395
- type: 'object',
396
- properties: {
397
- text: { type: 'string' },
398
- valence: { type: 'number' },
399
- arousal: { type: 'number' },
400
- },
401
- },
402
- to: {
403
- type: 'string',
404
- description:
405
- 'Target peer: either the peer display name (e.g. "claude-research-win") or the full nodeId. ' +
406
- 'Call sym_peers first if unsure which peers are connected. Omit to broadcast to all peers.',
407
- },
408
- payload: {
409
- description:
410
- 'Optional opaque payload riding alongside CAT7 fields. Use when carrying data beyond ' +
411
- 'CAT7 — e.g. an LLM request/response substrate protocol puts the prompt + request_id ' +
412
- 'in `payload` rather than smuggling JSON through `motivation` (which is reserved for ' +
413
- 'CAT7 semantics). Receivers see the payload via sym_fetch on the channel notification. ' +
414
- 'Any JSON-serializable value.',
415
- },
416
- },
417
- required: ['focus'],
418
- },
419
- },
420
- {
421
- name: 'sym_observe',
422
- description:
423
- 'Broadcast a structured CAT7 observation about your own state to all mesh peers. ' +
424
- 'Receivers run SVAF (MMP §9.2) and admitted CMBs are remix-stored with lineage. ' +
425
- '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.',
426
- inputSchema: {
427
- type: 'object',
428
- properties: {
429
- focus: { type: 'string' },
430
- issue: { type: 'string' },
431
- intent: { type: 'string' },
432
- motivation: { type: 'string' },
433
- commitment: { type: 'string' },
434
- perspective: { type: 'string' },
435
- mood: {
436
- type: 'object',
437
- properties: {
438
- text: { type: 'string' },
439
- valence: { type: 'number' },
440
- arousal: { type: 'number' },
441
- },
442
- },
443
- payload: {
444
- description:
445
- 'Optional opaque payload riding alongside CAT7 fields. Use when broadcasting data ' +
446
- 'beyond CAT7 (e.g. llm-capability-advertise carrying served_capabilities). ' +
447
- 'Any JSON-serializable value.',
448
- },
449
- },
450
- required: ['focus'],
451
- },
452
- },
453
- {
454
- name: 'sym_recall',
455
- description: 'Search mesh memory for relevant CMBs.',
456
- inputSchema: {
457
- type: 'object',
458
- properties: { query: { type: 'string', description: 'Search query (empty for all)' } },
459
- required: ['query'],
460
- },
461
- },
462
- {
463
- name: 'sym_peers',
464
- description: 'List connected mesh peers.',
465
- inputSchema: { type: 'object', properties: {} },
466
- },
467
- {
468
- name: 'sym_status',
469
- description: 'Get mesh node status — relay connection, peer count, memory count.',
470
- inputSchema: { type: 'object', properties: {} },
471
- },
472
- {
473
- name: 'sym_fetch',
474
- description: 'Fetch full content of a mesh message by ID. Use when a compact channel notification needs deeper reading.',
475
- inputSchema: {
476
- type: 'object',
477
- properties: { msg_id: { type: 'string', description: 'Message ID (e.g., m007)' } },
478
- required: ['msg_id'],
479
- },
480
- },
481
- {
482
- name: 'sym_group_info',
483
- description: 'Report the mesh group this node is in (MMP §5.8). Shows service type + group name + peer count.',
484
- inputSchema: { type: 'object', properties: {} },
485
- },
486
- {
487
- name: 'sym_invite_create',
488
- 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.',
489
- inputSchema: {
490
- type: 'object',
491
- properties: {
492
- group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team".' },
493
- relay_url: { type: 'string', description: 'Optional WebSocket relay URL, e.g. wss://sym-relay.onrender.com. Include for cross-network teams.' },
494
- relay_token: { type: 'string', description: 'Optional relay authentication token (shared secret for this team channel).' },
495
- },
496
- required: ['group'],
497
- },
498
- },
499
- {
500
- name: 'sym_invite_info',
501
- 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}).',
502
- inputSchema: {
503
- type: 'object',
504
- properties: { url: { type: 'string', description: 'Invite URL, e.g. sym://group/backend-team' } },
505
- required: ['url'],
506
- },
507
- },
508
- {
509
- name: 'sym_join_group',
510
- 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.',
511
- inputSchema: {
512
- type: 'object',
513
- properties: {
514
- group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team". Pass "default" to return to the global mesh.' },
515
- relay_url: { type: 'string', description: 'Optional WebSocket relay URL for cross-network teams. Leave empty for LAN-only.' },
516
- relay_token: { type: 'string', description: 'Optional relay authentication token.' },
517
- },
518
- required: ['group'],
519
- },
520
- },
521
- {
522
- name: 'sym_groups_discover',
523
- 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.',
524
- inputSchema: { type: 'object', properties: {} },
525
- },
526
- ],
527
- }));
528
-
529
- mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
530
- const { name, arguments: args } = request.params;
531
-
532
- switch (name) {
533
- case 'sym_send': {
534
- // Emit a structured CAT7 CMB per MMP §4.2. When args.to names a peer,
535
- // route as a targeted send (§4.4.4); otherwise broadcast. Receivers
536
- // run SVAF (§9.2) and remix-store on accept — no separate "message"
537
- // frame path, no raw-text channel.
538
- const fields = {
539
- focus: args.focus || 'directive',
540
- issue: args.issue || 'none',
541
- intent: args.intent || 'directive',
542
- motivation: args.motivation || '',
543
- commitment: args.commitment || '',
544
- perspective: args.perspective || NODE_NAME,
545
- mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
546
- };
547
-
548
- let targetPeerId = null;
549
- if (args.to) {
550
- const peers = node.peers();
551
- // Exact full-nodeId match first (unambiguous).
552
- const byNodeId = peers.filter(p => p.peerId === args.to);
553
- // Name match second.
554
- const byName = peers.filter(p => p.name === args.to);
555
- // Short-id prefix match last (for human-typed 8-char prefixes).
556
- const byPrefix = peers.filter(p => p.id === args.to);
557
-
558
- let matches;
559
- if (byNodeId.length > 0) matches = byNodeId;
560
- else if (byName.length > 0) matches = byName;
561
- else if (byPrefix.length > 0) matches = byPrefix;
562
- else matches = [];
563
-
564
- if (matches.length === 0) {
565
- return {
566
- content: [{ type: 'text', text: `Peer "${args.to}" not connected. Call sym_peers to see connected peers.` }],
567
- isError: true,
568
- };
569
- }
570
- if (matches.length > 1) {
571
- const names = matches.map(p => `${p.name} (${p.peerId})`).join(', ');
572
- return {
573
- content: [{ type: 'text', text: `Peer "${args.to}" is ambiguous — matches: ${names}. Pass the full nodeId.` }],
574
- isError: true,
575
- };
576
- }
577
- targetPeerId = matches[0].peerId;
578
- }
579
-
580
- const sendOpts = {};
581
- if (targetPeerId) sendOpts.to = targetPeerId;
582
- if (args.payload !== undefined && args.payload !== null) sendOpts.payload = args.payload;
583
- const entry = node.remember(fields, sendOpts);
584
- if (!entry) {
585
- return { content: [{ type: 'text', text: 'Duplicate — CMB already in memory, not re-broadcast.' }] };
586
- }
587
- const summary = targetPeerId
588
- ? `Sent CMB ${entry.key} to ${args.to}`
589
- : `Broadcast CMB ${entry.key} to all peers`;
590
- return { content: [{ type: 'text', text: summary }] };
591
- }
592
-
593
- case 'sym_observe': {
594
- const fields = {
595
- focus: args.focus || 'observation',
596
- issue: args.issue || 'none',
597
- intent: args.intent || 'observation',
598
- motivation: args.motivation || '',
599
- commitment: args.commitment || '',
600
- perspective: args.perspective || NODE_NAME,
601
- mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
602
- };
603
- const observeOpts = {};
604
- if (args.payload !== undefined && args.payload !== null) observeOpts.payload = args.payload;
605
- const entry = node.remember(fields, observeOpts);
606
- return { content: [{ type: 'text', text: entry ? `Observed: ${entry.key}` : 'Duplicate — already in memory.' }] };
607
- }
608
-
609
- case 'sym_recall': {
610
- const results = node.recall(args.query || '');
611
- if (results.length === 0) {
612
- return { content: [{ type: 'text', text: 'No memories found.' }] };
613
- }
614
- const lines = results.slice(0, 10).map(r => {
615
- const focus = r.cmb?.fields?.focus?.text || r.content || '';
616
- const source = r.source || r.cmb?.createdBy || 'unknown';
617
- const time = r.timestamp ? new Date(r.timestamp).toLocaleString() : '';
618
- return `[${source}] ${time}\n ${focus.slice(0, 150)}`;
619
- });
620
- return { content: [{ type: 'text', text: lines.join('\n\n') }] };
621
- }
622
-
623
- case 'sym_peers': {
624
- const peers = node.peers();
625
- if (peers.length === 0) {
626
- return { content: [{ type: 'text', text: 'No peers connected.' }] };
627
- }
628
- const lines = peers.map(p => `${p.name} via ${p.source || 'unknown'}`);
629
- return { content: [{ type: 'text', text: `${peers.length} peer(s):\n${lines.join('\n')}` }] };
630
- }
631
-
632
- case 'sym_fetch': {
633
- const entry = MESSAGE_STORE.get(args.msg_id);
634
- if (!entry) {
635
- return { content: [{ type: 'text', text: `Message ${args.msg_id} not found (expired or invalid ID).` }] };
636
- }
637
- return {
638
- content: [{
639
- type: 'text',
640
- text: `[${entry.from}] ${new Date(entry.timestamp).toISOString()}\n\n${entry.content}`,
641
- }],
642
- };
643
- }
644
-
645
- case 'sym_status': {
646
- const s = node.status();
647
- return {
648
- content: [{
649
- type: 'text',
650
- text: `Node: ${NODE_NAME} (${node.nodeId?.slice(0, 8) || '?'})\n` +
651
- `Group: ${GROUP} (${SERVICE_TYPE})\n` +
652
- `Relay: ${s.relayConnected ? 'connected' : 'disconnected'}\n` +
653
- `Peers: ${s.peerCount || 0}\n` +
654
- `Memories: ${s.memoryCount || 0}`,
655
- }],
656
- };
657
- }
658
-
659
- case 'sym_group_info': {
660
- const s = node.status();
661
- const peers = typeof node.getPeers === 'function' ? node.getPeers() : [];
662
- const peerLines = peers.length
663
- ? peers.map(p => ` ${p.name} (${(p.peerId || '').slice(0, 8)}) via ${p.transport || '?'}`).join('\n')
664
- : ' (no peers in this group)';
665
- return {
666
- content: [{
667
- type: 'text',
668
- text: `Mesh group (MMP §5.8):\n` +
669
- ` group: ${GROUP}\n` +
670
- ` service type: ${SERVICE_TYPE}\n` +
671
- ` node: ${NODE_NAME} (${node.nodeId?.slice(0, 8) || '?'})\n` +
672
- ` peers in group: ${s.peerCount || 0}\n` +
673
- peerLines + `\n\n` +
674
- `To join a different group, restart the sym-mesh-channel MCP server with env var SYM_GROUP=<name> or SYM_SERVICE_TYPE=<_foo._tcp>.`,
675
- }],
676
- };
677
- }
678
-
679
- case 'sym_invite_create': {
680
- const group = args?.group;
681
- const relayUrl = args?.relay_url;
682
- const relayToken = args?.relay_token;
683
- if (!group || typeof group !== 'string') {
684
- return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
685
- }
686
- if (!KEBAB_CASE_RE.test(group)) {
687
- return {
688
- content: [{
689
- type: 'text',
690
- text: `Invalid group name: "${group}". Must be kebab-case (lowercase alphanumerics + single hyphens), e.g. "backend-team".`,
691
- }],
692
- isError: true,
693
- };
694
- }
695
- // LAN-only flavor: sym://group/{name}
696
- // Cross-network flavor: sym://team/{name}?relay=...&token=...
697
- let url;
698
- let flavor;
699
- if (relayUrl || relayToken) {
700
- if (!relayUrl) return { content: [{ type: 'text', text: 'relay_token requires relay_url' }], isError: true };
701
- const params = [`relay=${encodeURIComponent(relayUrl)}`];
702
- if (relayToken) params.push(`token=${encodeURIComponent(relayToken)}`);
703
- url = `sym://team/${group}?${params.join('&')}`;
704
- flavor = 'cross-network (relay)';
705
- } else {
706
- url = `sym://group/${group}`;
707
- flavor = 'LAN-only (Bonjour)';
708
- }
709
- const youRunning = GROUP === group
710
- ? `You're already on this group — teammates who join will see you.`
711
- : `You are currently on group "${GROUP}". To be reachable, call sym_join_group with group="${group}" (+ same relay creds if cross-network) before sharing.`;
712
- return {
713
- content: [{
714
- type: 'text',
715
- text: `Invite URL (${flavor}):\n\n ${url}\n\n` +
716
- `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` +
717
- youRunning,
718
- }],
719
- };
720
- }
721
-
722
- case 'sym_invite_info': {
723
- const url = args?.url;
724
- if (!url || typeof url !== 'string') {
725
- return { content: [{ type: 'text', text: 'Missing required argument: url' }], isError: true };
726
- }
727
- const parsed = parseInviteURL(url);
728
- if (parsed.error) {
729
- return { content: [{ type: 'text', text: parsed.error }], isError: true };
730
- }
731
- const { appScheme, group, serviceType, roomId, roomName, relayUrl, relayToken } = parsed;
732
-
733
- const out = {
734
- app: appScheme,
735
- group,
736
- service_type: serviceType,
737
- room_id: appScheme === 'sym' ? undefined : roomId,
738
- room_name: appScheme === 'sym' ? undefined : roomName,
739
- relay_url: relayUrl || undefined,
740
- relay_token: relayToken || undefined,
741
- };
742
- for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
743
-
744
- const joinCall = {
745
- group,
746
- ...(relayUrl && { relay_url: relayUrl }),
747
- ...(relayToken && { relay_token: relayToken }),
748
- };
749
-
750
- return {
751
- content: [{
752
- type: 'text',
753
- text: `Parsed invite: ${url}\n\n` +
754
- JSON.stringify(out, null, 2) + `\n\n` +
755
- `To join, call sym_join_group:\n\n ${JSON.stringify(joinCall)}\n\n` +
756
- `This hot-swaps your node into the ${relayUrl ? 'relay channel' : 'LAN group'} — no Claude Code restart needed.`,
757
- }],
758
- };
759
- }
760
-
761
- case 'sym_join_group': {
762
- const group = args?.group;
763
- const relayUrl = args?.relay_url || null;
764
- const relayToken = args?.relay_token || null;
765
- if (!group || typeof group !== 'string') {
766
- return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
767
- }
768
- if (!KEBAB_CASE_RE.test(group) && group !== 'default') {
769
- return {
770
- content: [{ type: 'text', text: `Invalid group name: "${group}". Must be kebab-case or "default".` }],
771
- isError: true,
772
- };
773
- }
774
-
775
- const newServiceType = group === 'default' ? '_sym._tcp' : `_${group}._tcp`;
776
- const prevGroup = GROUP;
777
- const prevServiceType = SERVICE_TYPE;
778
-
779
- // Stop the current node cleanly so peers see us leave, then construct
780
- // a fresh one on the new service type. Any failure during restart is
781
- // reported; the previous node will already be stopped, so the caller
782
- // is in a known-disconnected state and can retry.
783
- try {
784
- await node.stop();
785
- } catch (e) {
786
- return {
787
- content: [{ type: 'text', text: `Failed to stop current node: ${e?.message || e}` }],
788
- isError: true,
789
- };
790
- }
791
-
792
- const newNode = new SymNode({
793
- name: NODE_NAME,
794
- cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
795
- svafFieldWeights: FIELD_WEIGHTS,
796
- svafFreshnessSeconds: 7200,
797
- discoveryServiceType: newServiceType,
798
- group,
799
- relay: relayUrl,
800
- relayToken,
801
- silent: true,
802
- });
803
- registerNodeHandlers(newNode);
804
-
805
- try {
806
- await newNode.start();
807
- } catch (e) {
808
- return {
809
- content: [{
810
- type: 'text',
811
- text: `Failed to start new node on group "${group}": ${e?.message || e}\n\n` +
812
- `Previous node already stopped. To recover, call sym_join_group with group="${prevGroup}".`,
813
- }],
814
- isError: true,
815
- };
816
- }
817
-
818
- // Swap module-level references only after successful start.
819
- node = newNode;
820
- GROUP = group;
821
- SERVICE_TYPE = newServiceType;
822
- RELAY_URL = relayUrl;
823
- RELAY_TOKEN = relayToken;
824
-
825
- publishGroupBeacon(); // re-advertise the new group on _symgroups._tcp
826
-
827
- return {
828
- content: [{
829
- type: 'text',
830
- text: `Hot-swapped from group "${prevGroup}" (${prevServiceType}) to "${group}" (${newServiceType}).\n` +
831
- (relayUrl ? `Relay: ${relayUrl}\n` : '') +
832
- `Discovering peers on the new service type. Call sym_peers in a moment to see who's online.`,
833
- }],
834
- };
835
- }
836
-
837
- case 'sym_groups_discover': {
838
- const result = await discoverGroups();
839
- return {
840
- content: [{
841
- type: 'text',
842
- text: result.text,
843
- }],
844
- isError: result.isError || false,
845
- };
846
- }
847
-
848
- default:
849
- return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
850
- }
851
- });
852
-
853
- // ── Compact Channel — message store for lazy-load (v0.1) ────
854
- // Per COO spec cmb_compact_channel_v0.1.md: push compact headers,
855
- // store full content for on-demand sym_fetch retrieval. ~10% token
856
- // savings on mesh traffic without context loss.
857
- const MESSAGE_STORE = new Map();
858
- let msgSeq = 0;
859
- const MAX_STORED = 200;
860
-
861
- function storeMessage(from, content) {
862
- const msgId = `m${String(++msgSeq).padStart(3, '0')}`;
863
- MESSAGE_STORE.set(msgId, { from, content, timestamp: Date.now() });
864
- while (MESSAGE_STORE.size > MAX_STORED) {
865
- const oldest = MESSAGE_STORE.keys().next().value;
866
- MESSAGE_STORE.delete(oldest);
867
- }
868
- return msgId;
869
- }
870
-
871
- function extractCompactHeader(from, content) {
872
- const lines = content.split('\n').filter(l => l.trim());
873
- const focusMatch = content.match(/focus[=:]\s*([^\n\]]{0,80})/i);
874
- const bracketMatch = content.match(/\[([^\]]{0,120})\]/);
875
-
876
- const hasHalt = /\bhalt\b/i.test(content);
877
- const hasDirective = /\bdirective\b/i.test(content);
878
- const hasResults = /\bresult|complete|landed|done\b/i.test(content);
879
- const hasAck = /\back\b/i.test(content);
880
-
881
- let signal = '';
882
- if (hasHalt) signal = 'HALT';
883
- else if (hasDirective) signal = 'DIRECTIVE';
884
- else if (hasResults) signal = 'RESULT';
885
- else if (hasAck) signal = 'ACK';
886
-
887
- const parts = [];
888
- if (signal) parts.push(signal);
889
- if (focusMatch) parts.push(`focus=${focusMatch[1].trim()}`);
890
- else if (bracketMatch) parts.push(bracketMatch[1].trim());
891
- else if (lines[0]) parts.push(lines[0].slice(0, 100));
892
-
893
- const approxTokens = Math.round(content.length / 4);
894
- return parts.join(' | ') + ` (~${approxTokens}tok)`;
895
- }
896
-
897
- // ── Peer Allowlist (optional, defense-in-depth) ─────────────
898
- // SYM_ALLOWED_PEERS is a comma-separated list of peer node names.
899
- // When set, only CMBs and messages from listed peers are pushed to
900
- // Claude's context. When empty/unset, all authenticated peers are
901
- // accepted (SVAF still gates on content relevance).
902
- const ALLOWED_PEERS = (process.env.SYM_ALLOWED_PEERS || '')
903
- .split(',')
904
- .map(s => s.trim())
905
- .filter(Boolean);
906
-
907
- function isPeerAllowed(peerName) {
908
- if (ALLOWED_PEERS.length === 0) return true; // no allowlist = accept all
909
- return ALLOWED_PEERS.includes(peerName);
910
- }
911
-
912
- // ── Security: Prompt-Injection Filter (v0.3.11) ──────────────
913
- // SVAF gates on semantic relevance; this layer gates on safety.
914
- // It runs on every CAT7 field and payload before pushChannel —
915
- // the last line of defence before content enters Claude's context.
916
- //
917
- // Attack model: a peer with a valid Ed25519 identity sends a CMB
918
- // whose fields look topically relevant (passes SVAF) but whose
919
- // content contains instruction-override patterns designed to hijack
920
- // the receiving Claude session ("ignore previous instructions",
921
- // role-play overrides, tool-call fabrication, etc.).
922
- //
923
- // Strategy: pattern-match on the serialized content of all CAT7
924
- // fields and the opaque payload. On match: block + audit-log to
925
- // stderr. Never silently drop — the operator must be able to see
926
- // what was rejected and why.
927
-
928
- const INJECTION_PATTERNS = [
929
- // Classic instruction overrides
930
- /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|context|rules?|guidelines?)/i,
931
- /disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|context|rules?)/i,
932
- /forget\s+(everything|all)\s+(you('ve)?\s+)?(know|been\s+told|learned)/i,
933
-
934
- // Role / persona hijacking
935
- /you\s+are\s+now\s+(a\s+|an\s+)?(new\s+)?(ai|assistant|model|system|gpt|claude|llm)/i,
936
- /act\s+as\s+(a\s+|an\s+)?(different|new|unrestricted|jailbroken|evil|rogue)/i,
937
- /pretend\s+(you\s+)?(are|have\s+no)\s+(restrictions?|rules?|guidelines?|ethics?)/i,
938
- /new\s+(persona|personality|mode|role)\s*:/i,
939
-
940
- // System prompt injection
941
- /<\s*system\s*>/i,
942
- /\[SYSTEM\]/,
943
- /##\s*system\s+prompt/i,
944
- /---\s*system\s*---/i,
945
-
946
- // Tool / function call fabrication
947
- /<\s*tool_call\s*>/i,
948
- /<\s*function_calls?\s*>/i,
949
- /\{"type"\s*:\s*"tool_use"/,
950
-
951
- // Privilege / capability escalation
952
- /you\s+(now\s+)?(have|possess)\s+(full|unrestricted|admin|root|elevated)\s+(access|permissions?|capabilities?)/i,
953
- /override\s+(safety|content|ethical?|policy)\s+(filter|check|guard|restriction)/i,
954
- /jailbreak/i,
955
- /DAN\s+mode/i,
956
- ];
957
-
958
- const PAYLOAD_SIZE_LIMIT = parseInt(process.env.SYM_MAX_PAYLOAD_BYTES || '8192', 10);
959
-
960
- // Per-peer rate limiter: sliding window, default 30 CMBs/min.
961
- const RATE_LIMIT = parseInt(process.env.SYM_RATE_LIMIT || '30', 10);
962
- const RATE_WINDOW_MS = 60_000;
963
- const peerWindows = new Map(); // peerName → timestamp[]
964
-
965
- function isRateLimited(peer) {
966
- const now = Date.now();
967
- const window = (peerWindows.get(peer) || []).filter(t => now - t < RATE_WINDOW_MS);
968
- window.push(now);
969
- peerWindows.set(peer, window);
970
- return window.length > RATE_LIMIT;
971
- }
972
-
973
- function securityAudit(reason, peer, excerpt) {
974
- const safe = String(excerpt).replace(/[\r\n]+/g, ' ').slice(0, 120);
975
- process.stderr.write(`[sym-security] BLOCKED reason=${reason} peer=${peer} excerpt="${safe}"\n`);
976
- }
977
-
978
- // Returns { safe: true } or { safe: false, reason, excerpt }.
979
- function checkSecurity(peer, fields, payload) {
980
- // 1. Rate limit
981
- if (isRateLimited(peer)) {
982
- return { safe: false, reason: 'rate-limit', excerpt: `>${RATE_LIMIT} CMBs/min` };
983
- }
984
-
985
- // 2. Payload size cap
986
- if (payload !== undefined && payload !== null) {
987
- const size = JSON.stringify(payload).length;
988
- if (size > PAYLOAD_SIZE_LIMIT) {
989
- return { safe: false, reason: 'payload-too-large', excerpt: `${size}b > ${PAYLOAD_SIZE_LIMIT}b limit` };
990
- }
991
- }
992
-
993
- // 3. Prompt injection scan across all text surfaces
994
- const surfaces = [
995
- ...Object.values(fields || {}).map(v =>
996
- typeof v === 'string' ? v : (typeof v === 'object' && v?.text ? v.text : '')
997
- ),
998
- payload !== undefined && payload !== null
999
- ? (typeof payload === 'string' ? payload : JSON.stringify(payload))
1000
- : '',
1001
- ].filter(Boolean);
1002
-
1003
- for (const surface of surfaces) {
1004
- for (const pattern of INJECTION_PATTERNS) {
1005
- if (pattern.test(surface)) {
1006
- return { safe: false, reason: 'injection-pattern', excerpt: surface.slice(0, 200) };
1007
- }
1008
- }
1009
- }
1010
-
1011
- return { safe: true };
1012
- }
1013
-
1014
- // ── Mesh Events → Channel Notifications ──────────────────────
1015
-
1016
- function pushChannel(eventType, data) {
1017
- try {
1018
- mcp.notification({
1019
- method: 'notifications/claude/channel',
1020
- params: {
1021
- content: typeof data === 'string' ? data : JSON.stringify(data),
1022
- meta: { event_type: eventType, source: 'sym-mesh' },
1023
- },
1024
- });
1025
- } catch {}
1026
- }
1027
-
1028
- // All node.on(...) handlers live in registerNodeHandlers(n) above so the
1029
- // hot-swap path in sym_join_group can attach them to a freshly-constructed
1030
- // SymNode without duplicating logic. This call wires up the initial node.
1031
- registerNodeHandlers(node);
1032
-
1033
- // Peer presence events are intentionally NOT pushed to Claude's context.
1034
- // They're high-frequency, low-signal (peers flap on relay reconnects, daemon
1035
- // restarts, NAT keepalive blips), and a flood will eat the context window.
1036
- // Use sym_peers / sym_status on demand instead. Only CMBs and direct messages
1037
- // are surfaced as channel notifications — those carry actual cognitive payload.
1038
-
1039
- // ── Start ────────────────────────────────────────────────────
1040
-
1041
- // Clean shutdown — disconnect from the relay before exiting so other peers
1042
- // see us leave immediately, and so a fast restart of this MCP doesn't race
1043
- // our own zombie connection on the relay (which would trigger the relay's
1044
- // duplicate-nodeId replacement path and cause peer flap loops).
1045
- //
1046
- // Idempotent: Claude Code may send SIGTERM and then SIGKILL; we want the
1047
- // first signal to get us cleanly off the relay even if the second one
1048
- // arrives before stop() resolves.
1049
- let shuttingDown = false;
1050
- async function shutdown(signal) {
1051
- if (shuttingDown) return;
1052
- shuttingDown = true;
1053
- stopGroupBeacon();
1054
- try {
1055
- await node.stop();
1056
- } catch {
1057
- // Best effort — we're exiting anyway. Don't block on cleanup errors.
1058
- }
1059
- process.exit(0);
1060
- }
1061
-
1062
- process.on('SIGTERM', () => shutdown('SIGTERM'));
1063
- process.on('SIGINT', () => shutdown('SIGINT'));
1064
- process.on('SIGHUP', () => shutdown('SIGHUP'));
1065
-
1066
- // ── Group discovery beacon (MMP §5.8) ──────────────────────────
1067
- // Mirror the sym CLI daemon: advertise this node's group on the shared
1068
- // `_symgroups._tcp` service (group name in TXT) via the pure-JS bonjour-service,
1069
- // so `sym groups` lists this Claude/MCP node cross-platform alongside
1070
- // CLI-daemon nodes. Discovery-only — comms stay on the group's own
1071
- // `_<group>._tcp`. Re-published on group hot-swap; torn down on shutdown.
1072
- let groupBeacon = null;
1073
- function publishGroupBeacon() {
1074
- try {
1075
- const { Bonjour } = require('bonjour-service');
1076
- if (groupBeacon) { try { groupBeacon.unpublishAll(); groupBeacon.destroy(); } catch {} groupBeacon = null; }
1077
- groupBeacon = new Bonjour();
1078
- groupBeacon.publish({ name: NODE_NAME, type: 'symgroups', port: (node && node._port) || 7777, txt: { group: GROUP, node: NODE_NAME } });
1079
- } catch (e) {
1080
- process.stderr.write(`group beacon unavailable: ${e?.message || e}\n`);
1081
- }
1082
- }
1083
- function stopGroupBeacon() {
1084
- if (!groupBeacon) return;
1085
- try { groupBeacon.unpublishAll(() => { try { groupBeacon.destroy(); } catch {} }); } catch {}
1086
- groupBeacon = null;
1087
- }
1088
-
1089
- async function main() {
1090
- // Start SymNode — connects to relay as a peer. The startup primer is
1091
- // computed at module-load time (see BASE_INSTRUCTIONS above) and is
1092
- // already embedded in the MCP server's initialize-response payload.
1093
- await node.start();
1094
-
1095
- publishGroupBeacon();
1096
-
1097
- // Start MCP server — communicates with Claude Code via stdio
1098
- const transport = new StdioServerTransport();
1099
- await mcp.connect(transport);
1100
- }
1101
-
1102
- main().catch((err) => {
1103
- process.stderr.write(`sym-mesh-channel failed: ${err.message}\n`);
1104
- process.exit(1);
1105
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Subcommand dispatch: `sym-mesh-channel init` runs the installer.
5
+ if (process.argv[2] === 'init') {
6
+ require('./bin/install.js');
7
+ return;
8
+ }
9
+
10
+ // ── stdout discipline (v0.3.9) ──────────────────────────────────────────────
11
+ // MCP frames JSON-RPC on stdout. Any non-JSON write there — ours or, far more
12
+ // often, a dependency's load banner (e.g. "[encoder] Semantic encoder ready"
13
+ // from the semantic model) — corrupts the stream and makes Claude Code drop the
14
+ // connection (-32000) or log "Ignoring non-JSON line on stdout". Guard it: lines
15
+ // that look like JSON-RPC (start with '{') pass through to the real stdout;
16
+ // everything else is redirected to stderr. Installed before any require so it
17
+ // catches dependency output at load time.
18
+ const __realStdoutWrite = process.stdout.write.bind(process.stdout);
19
+ process.stdout.write = function (chunk, ...rest) {
20
+ try {
21
+ const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
22
+ if (s.trimStart().startsWith('{')) return __realStdoutWrite(chunk, ...rest);
23
+ return process.stderr.write(chunk, ...rest);
24
+ } catch {
25
+ return __realStdoutWrite(chunk, ...rest);
26
+ }
27
+ };
28
+
29
+ /**
30
+ * sym-mesh-channel — MCP server that makes Claude Code a peer node on the SYM mesh.
31
+ *
32
+ * Architecture (MMP Section 13.9: Local Event Interface):
33
+ * SymNode (own identity, own SVAF field weights) → relay → mesh
34
+ * MCP channel notifications → Claude Code (real-time push)
35
+ * MCP tools → SymNode methods (send, observe, recall)
36
+ *
37
+ * This is a PEER NODE, not a client of the daemon. It has its own identity,
38
+ * its own relay connection, and its own SVAF evaluation with engineering-domain
39
+ * field weights. Per MMP Section 3: every participant is a peer.
40
+ *
41
+ * Copyright (c) 2026 SYM.BOT. Apache 2.0 License.
42
+ */
43
+
44
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
45
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
46
+ const {
47
+ CallToolRequestSchema,
48
+ ListToolsRequestSchema,
49
+ } = require('@modelcontextprotocol/sdk/types.js');
50
+ const { SymNode } = require('@sym-bot/sym');
51
+
52
+ // Kebab-case validator shared by group-related tools.
53
+ const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
54
+
55
+ // ── Invite URL parsing (shared by sym_invite_info and the internal
56
+ // validation path for sym_join_group when passed a URL). Exposed as
57
+ // a module-level function so it's trivially unit-testable and the
58
+ // same regex doesn't drift between two call sites.
59
+
60
+ const INVITE_URL_RE = /^([a-z][a-z0-9-]+):\/\/(?:room|group|team)\/([^/?#]+)(?:\/([^?#]+))?(?:\?(.+))?$/i;
61
+
62
+ function parseInviteURL(url) {
63
+ const m = INVITE_URL_RE.exec(url);
64
+ if (!m) {
65
+ return {
66
+ error:
67
+ `Unrecognised invite URL: ${url}\n\n` +
68
+ `Expected shapes:\n` +
69
+ ` sym://group/{name} (LAN-only)\n` +
70
+ ` sym://team/{name}?relay=...&token=... (cross-network via relay)\n` +
71
+ ` melotune://room/{id}/{name} (app-specific room)`,
72
+ };
73
+ }
74
+ const appScheme = m[1].toLowerCase();
75
+ const rawId = decodeURIComponent(m[2]);
76
+ const rawName = m[3] ? decodeURIComponent(m[3]) : rawId;
77
+ const queryStr = m[4] || '';
78
+ const query = Object.fromEntries(
79
+ queryStr.split('&').filter(Boolean).map(kv => {
80
+ const [k, v = ''] = kv.split('=');
81
+ return [decodeURIComponent(k), decodeURIComponent(v)];
82
+ })
83
+ );
84
+ // For sym:// the path element IS the group name. For app-scoped URLs
85
+ // (melotune://, melomove://, etc.) the path is the room id and the
86
+ // group is prefixed with the app name to avoid collisions.
87
+ const serviceType = appScheme === 'sym' ? `_${rawId}._tcp` : `_${appScheme}-${rawId}._tcp`;
88
+ const group = appScheme === 'sym' ? rawId : `${appScheme}-${rawId}`;
89
+ return {
90
+ appScheme,
91
+ group,
92
+ serviceType,
93
+ roomId: rawId,
94
+ roomName: rawName,
95
+ relayUrl: query.relay || null,
96
+ relayToken: query.token || null,
97
+ };
98
+ }
99
+
100
+ // ── Bonjour discovery of live SYM-related service types.
101
+ // Runs `dns-sd -B _services._dns-sd._udp local.` (macOS / Windows with
102
+ // Bonjour) or `avahi-browse -at` (Linux) for 2 seconds, filters to
103
+ // service types that look SYM-ish, and reports them. Pure observation,
104
+ // no node state changes.
105
+
106
+ async function discoverGroups() {
107
+ const { spawn } = require('child_process');
108
+ const platform = process.platform;
109
+
110
+ let cmd, argv;
111
+ if (platform === 'darwin' || platform === 'win32') {
112
+ cmd = 'dns-sd';
113
+ argv = ['-B', '_services._dns-sd._udp', 'local.'];
114
+ } else {
115
+ cmd = 'avahi-browse';
116
+ argv = ['-t', '-a', '-p']; // terminate after cache, all services, parseable
117
+ }
118
+
119
+ return new Promise((resolve) => {
120
+ let child;
121
+ try {
122
+ child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
123
+ } catch (e) {
124
+ return resolve({
125
+ isError: true,
126
+ text:
127
+ `Could not run discovery command '${cmd}': ${e?.message || e}\n\n` +
128
+ (platform === 'linux'
129
+ ? `On Linux, install avahi-utils: sudo apt install avahi-utils`
130
+ : `Bonjour should be built-in on macOS and Windows 10+.`),
131
+ });
132
+ }
133
+ const out = [];
134
+ child.stdout.on('data', (chunk) => out.push(chunk));
135
+ child.on('error', (e) => resolve({ isError: true, text: `Discovery command failed: ${e?.message || e}` }));
136
+
137
+ const timer = setTimeout(() => {
138
+ try { child.kill('SIGTERM'); } catch {}
139
+ }, 2000);
140
+ child.on('close', () => {
141
+ clearTimeout(timer);
142
+ const text = Buffer.concat(out).toString('utf8');
143
+ const typeRe = /_([a-z0-9][a-z0-9-]+)\._tcp/gi;
144
+ const seen = new Set();
145
+ let m;
146
+ while ((m = typeRe.exec(text)) !== null) {
147
+ const full = `_${m[1]}._tcp`;
148
+ // Filter to the SYM protocol family: global sym, named groups, and
149
+ // app-scoped rooms (melotune-<id>, melomove-<id>, etc). Anything
150
+ // that looks like generic infra (_services._dns-sd, _tcp, _udp,
151
+ // printer protocols, etc.) is ignored.
152
+ if (/^_(sym|[a-z]+-[a-z0-9]+|[a-z]+-team|.*-team)\._tcp$/i.test(full)) {
153
+ seen.add(full);
154
+ }
155
+ }
156
+ if (seen.size === 0) {
157
+ return resolve({
158
+ text:
159
+ `No SYM-mesh groups visible on the local network right now.\n\n` +
160
+ `This only shows groups with at least one node currently online. ` +
161
+ `Groups you or teammates have used before are not persisted anywhere ` +
162
+ `(p2p architecture — no central directory).\n\n` +
163
+ `Your node is on: ${SERVICE_TYPE} (group "${GROUP}").`,
164
+ });
165
+ }
166
+ const lines = [];
167
+ lines.push(`SYM-mesh groups visible on LAN (${seen.size}):`);
168
+ for (const st of Array.from(seen).sort()) {
169
+ const name = st.replace(/^_/, '').replace(/\._tcp$/, '');
170
+ const isSelf = st === SERVICE_TYPE ? ' (← your current group)' : '';
171
+ lines.push(` ${st} group="${name}"${isSelf}`);
172
+ }
173
+ lines.push('');
174
+ lines.push(`To join one, call sym_join_group with group="<name>".`);
175
+ resolve({ text: lines.join('\n') });
176
+ });
177
+ });
178
+ }
179
+
180
+ // ── Engineering-domain field weights (SVAF α_f) ──────────────
181
+
182
+ const FIELD_WEIGHTS = {
183
+ focus: 2.0, // code, architecture, technical decisions
184
+ issue: 2.0, // bugs, blockers, technical debt
185
+ intent: 1.5, // what needs building
186
+ motivation: 1.0, // why it matters
187
+ commitment: 1.5, // deadlines, dependencies
188
+ perspective: 0.5, // viewpoint — low for engineering
189
+ mood: 0.8, // user fatigue affects code quality
190
+ };
191
+
192
+ // ── SymNode — full peer on the mesh ──────────────────────────
193
+
194
+ // Default: hostname-based identity, unique per machine. The old default
195
+ // ('claude-code-mac') caused ghost-peer bugs when another machine ran
196
+ // without SYM_NODE_NAME set — both machines claimed the same name with
197
+ // different nodeIds, creating phantom peers that absorbed messages.
198
+ // Per-session default (v0.3.8): keep co-resident Claude Code sessions from all
199
+ // claiming one shared identity and colliding on the identity lock. Each Claude
200
+ // Code session exposes CLAUDE_CODE_SESSION_ID (stable across `--resume`) and
201
+ // CLAUDE_PROJECT_DIR, so the default becomes `claude-<repo>-<session6>` —
202
+ // unique even for two sessions in the same repo, readable, and stable across
203
+ // resume. Bare-npm use (no session id) keeps the hostname default. Named agents
204
+ // override with SYM_NODE_NAME (e.g. claude-code-mac, melotune-dev).
205
+ function defaultNodeName() {
206
+ const clean = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '');
207
+ const sid = clean(process.env.CLAUDE_CODE_SESSION_ID).slice(0, 6);
208
+ if (sid) {
209
+ const repo = clean(require('path').basename(process.env.CLAUDE_PROJECT_DIR || process.cwd())) || 'session';
210
+ return `claude-${repo}-${sid}`;
211
+ }
212
+ return `claude-${clean(require('os').hostname())}`;
213
+ }
214
+ // Live-collision auto-suffix (v0.3.10): @sym-bot/sym already reclaims STALE locks
215
+ // (dead holder), so crashed sessions self-heal. But two LIVE sessions wanting the
216
+ // same name — a duplicate dev agent, or two sessions sharing a fixed SYM_NODE_NAME
217
+ // — would hard-fail with EIDENTITYLOCK. Resolve the name up front: if the base is
218
+ // held by a live process, append -2/-3/… so the second session coexists instead of
219
+ // failing. A dead or absent holder keeps the base name (sym reclaims it on start).
220
+ function resolveNodeName(base) {
221
+ const fs = require('fs'), os = require('os'), path = require('path');
222
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
223
+ for (let i = 0; i < 64; i++) {
224
+ const name = i === 0 ? base : `${base}-${i + 1}`;
225
+ try {
226
+ const pid = parseInt(fs.readFileSync(path.join(os.homedir(), '.sym', 'nodes', name, 'lock.pid'), 'utf8').trim(), 10);
227
+ if (pid && alive(pid)) continue; // live holder → try the next suffix
228
+ } catch { /* no lock file → name is free */ }
229
+ return name; // free, or a stale lock sym will reclaim on start()
230
+ }
231
+ return base;
232
+ }
233
+ const NODE_NAME = resolveNodeName(process.env.SYM_NODE_NAME || defaultNodeName());
234
+
235
+ // ── Mesh group (MMP §5.8) ──────────────────────────────────
236
+ //
237
+ // LAN isolation by Bonjour service type. `_sym._tcp` is the default
238
+ // (backward compatible). A named group `<foo>` maps to service type
239
+ // `_foo._tcp`. Passing a full `_foo._tcp` service type explicitly also
240
+ // works. Nodes in different groups never discover each other at mDNS.
241
+ // See MeloTune's MoodRoom model for the per-room pattern
242
+ // (`_melotune-{id}._tcp`).
243
+ function resolveServiceType() {
244
+ const explicit = process.env.SYM_SERVICE_TYPE;
245
+ if (explicit) return explicit;
246
+ const group = process.env.SYM_GROUP;
247
+ if (group && group !== 'default') return `_${group}._tcp`;
248
+ return '_sym._tcp';
249
+ }
250
+ // Mutable so sym_join_group can hot-swap the node at runtime without a
251
+ // Claude Code restart. Declaring as `let` rather than `const` is the
252
+ // smallest change that makes hot-swap possible.
253
+ let SERVICE_TYPE = resolveServiceType();
254
+ let GROUP = process.env.SYM_GROUP || (SERVICE_TYPE !== '_sym._tcp'
255
+ ? SERVICE_TYPE.replace(/^_/, '').replace(/\._tcp$/, '')
256
+ : 'default');
257
+ let RELAY_URL = process.env.SYM_RELAY_URL || null;
258
+ let RELAY_TOKEN = process.env.SYM_RELAY_TOKEN || null;
259
+
260
+ let node = new SymNode({
261
+ name: NODE_NAME,
262
+ cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
263
+ svafFieldWeights: FIELD_WEIGHTS,
264
+ svafFreshnessSeconds: 7200, // 2hr — session-length context
265
+ discoveryServiceType: SERVICE_TYPE,
266
+ group: GROUP,
267
+ relay: RELAY_URL,
268
+ relayToken: RELAY_TOKEN,
269
+ silent: true,
270
+ });
271
+
272
+ // Event handlers are extracted into a single registration function so the
273
+ // hot-swap path in sym_join_group can re-register them on the new node.
274
+ // The function reads module-level `NODE_NAME`, `isPeerAllowed`, `pushChannel`,
275
+ // `storeMessage`, and `extractCompactHeader` via closure; those don't change
276
+ // across swaps.
277
+ function registerNodeHandlers(n) {
278
+ // Identity collision (added in @sym-bot/sym 0.3.68): the relay told us
279
+ // another process is holding our nodeId. Don't try to reconnect — that
280
+ // caused the peer-flap loop documented in v0.1.2/v0.1.3 commit messages.
281
+ // Exit so Claude Code can decide whether to respawn (with the freshness
282
+ // window now elapsed) or surface the failure to the user.
283
+ n.on('identity-collision', (info) => {
284
+ process.stderr.write(
285
+ `sym-mesh-channel: identity collision on relay — another process is holding ` +
286
+ `nodeId=${info.nodeId} name=${info.name}. Exiting.\n`
287
+ );
288
+ process.exit(2);
289
+ });
290
+
291
+ n.on('cmb-accepted', (entry) => {
292
+ if (entry.source === NODE_NAME || entry.cmb?.createdBy === NODE_NAME) return;
293
+ const source = entry.source || entry.cmb?.createdBy || 'unknown';
294
+ if (!isPeerAllowed(source)) return;
295
+ const fields = entry.cmb?.fields || {};
296
+ const payload = entry.cmb?.payload;
297
+ const sec = checkSecurity(source, fields, payload);
298
+ if (!sec.safe) { securityAudit(sec.reason, source, sec.excerpt); return; }
299
+ const focus = fields?.focus?.text || entry.content || '';
300
+ const mood = fields?.mood?.text || '';
301
+ const moodSuffix = mood && mood !== 'neutral' ? ` (mood: ${mood})` : '';
302
+ // Store the rendered CMB body so the agent can sym_fetch it by [mNNN] ID.
303
+ // When the CMB carries an opaque payload alongside CAT7 fields, append a
304
+ // PAYLOAD section to the stored body so sym_fetch returns it intact;
305
+ // header gains a [+payload Nb] indicator so the receiver knows there's
306
+ // structured data beyond CAT7 and should sym_fetch to consume it.
307
+ const hasPayload = payload !== undefined && payload !== null;
308
+ let body = entry.content || focus;
309
+ let payloadSuffix = '';
310
+ if (hasPayload) {
311
+ const serialized = (() => {
312
+ try { return JSON.stringify(payload, null, 2); }
313
+ catch { return String(payload); }
314
+ })();
315
+ body = `${body}\n\n---PAYLOAD---\n${serialized}`;
316
+ payloadSuffix = ` [+payload ${serialized.length}b]`;
317
+ }
318
+ const msgId = storeMessage(source, body);
319
+ pushChannel('cmb', `[${source}] ${focus}${moodSuffix}${payloadSuffix} [${msgId}]`);
320
+ });
321
+
322
+ n.on('message', (from, content) => {
323
+ if (!isPeerAllowed(from)) return;
324
+ const sec = checkSecurity(from, { focus: { text: content } }, null);
325
+ if (!sec.safe) { securityAudit(sec.reason, from, sec.excerpt); return; }
326
+ const msgId = storeMessage(from, content);
327
+ const header = extractCompactHeader(from, content);
328
+ pushChannel('message', `[${from}] ${header} [${msgId}]`);
329
+ });
330
+ }
331
+
332
+ // ── MCP Server ───────────────────────────────────────────────
333
+
334
+ // Base instructions shown to the agent at every MCP initialize.
335
+ const BASE_INSTRUCTIONS =
336
+ `You are a peer node on the SYM mesh (identity: ${NODE_NAME}). ` +
337
+ 'Mesh events arrive as <channel> notifications in real-time. ' +
338
+ '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). ' +
339
+ 'Share observations about your own state with the whole mesh via sym_observe (MMP §9.2 receiver-autonomous SVAF evaluation). ' +
340
+ 'Both sym_send and sym_observe emit CAT7 CMBs; receivers run SVAF and, if admitted, remix-store with lineage pointing back to your CMB. ' +
341
+ 'Search mesh memory via sym_recall. ' +
342
+ '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.';
343
+
344
+ // Final startup step (MMP §4.2 O2 — rejoin-without-replay). The SymNode
345
+ // constructor builds the memory-store index from disk, so the primer is
346
+ // available synchronously without needing node.start(). Appending it to
347
+ // the MCP instructions payload means a fresh Claude Code session wakes
348
+ // with prior remix memory — own observations plus peer observations
349
+ // admitted by SVAF — already loaded into context, zero first-turn
350
+ // sym_recall overhead.
351
+ //
352
+ // MCP SDK reads `instructions` at Server construction time (storing it in
353
+ // a private field) and emits it only on initialize-response; mutations on
354
+ // the public property after construction are ignored. Compute once, pass in.
355
+ let primerText = '';
356
+ try {
357
+ const primer = node.buildStartupPrimer();
358
+ if (primer && primer.count > 0) primerText = `\n\n${primer.text}`;
359
+ } catch (err) {
360
+ process.stderr.write(`sym-mesh-channel startup primer skipped: ${err?.message || err}\n`);
361
+ }
362
+
363
+ const mcp = new Server(
364
+ { name: 'sym-mesh', version: '0.1.0' },
365
+ {
366
+ capabilities: {
367
+ tools: {},
368
+ experimental: { 'claude/channel': {} },
369
+ },
370
+ instructions: BASE_INSTRUCTIONS + primerText,
371
+ },
372
+ );
373
+
374
+ // ── Tools ────────────────────────────────────────────────────
375
+
376
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
377
+ tools: [
378
+ {
379
+ name: 'sym_send',
380
+ description:
381
+ 'Send a structured CAT7 CMB to a specific mesh peer (targeted) or to all peers (broadcast, when "to" is omitted). ' +
382
+ 'Receivers evaluate the CMB per-field via SVAF (MMP §9.2) and, if admitted, remix-store it with lineage pointing back to this CMB. ' +
383
+ 'Use sym_send when the CMB is for a specific peer (e.g. a peer-review gating request directed at the reviewer role); ' +
384
+ 'use sym_observe when sharing your own state with the whole mesh.',
385
+ inputSchema: {
386
+ type: 'object',
387
+ properties: {
388
+ focus: { type: 'string', description: 'The task anchor / what this CMB is about. Required.' },
389
+ issue: { type: 'string' },
390
+ intent: { type: 'string' },
391
+ motivation: { type: 'string' },
392
+ commitment: { type: 'string' },
393
+ perspective: { type: 'string' },
394
+ mood: {
395
+ type: 'object',
396
+ properties: {
397
+ text: { type: 'string' },
398
+ valence: { type: 'number' },
399
+ arousal: { type: 'number' },
400
+ },
401
+ },
402
+ to: {
403
+ type: 'string',
404
+ description:
405
+ 'Target peer: either the peer display name (e.g. "claude-research-win") or the full nodeId. ' +
406
+ 'Call sym_peers first if unsure which peers are connected. Omit to broadcast to all peers.',
407
+ },
408
+ payload: {
409
+ description:
410
+ 'Optional opaque payload riding alongside CAT7 fields. Use when carrying data beyond ' +
411
+ 'CAT7 — e.g. an LLM request/response substrate protocol puts the prompt + request_id ' +
412
+ 'in `payload` rather than smuggling JSON through `motivation` (which is reserved for ' +
413
+ 'CAT7 semantics). Receivers see the payload via sym_fetch on the channel notification. ' +
414
+ 'Any JSON-serializable value.',
415
+ },
416
+ },
417
+ required: ['focus'],
418
+ },
419
+ },
420
+ {
421
+ name: 'sym_observe',
422
+ description:
423
+ 'Broadcast a structured CAT7 observation about your own state to all mesh peers. ' +
424
+ 'Receivers run SVAF (MMP §9.2) and admitted CMBs are remix-stored with lineage. ' +
425
+ '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.',
426
+ inputSchema: {
427
+ type: 'object',
428
+ properties: {
429
+ focus: { type: 'string' },
430
+ issue: { type: 'string' },
431
+ intent: { type: 'string' },
432
+ motivation: { type: 'string' },
433
+ commitment: { type: 'string' },
434
+ perspective: { type: 'string' },
435
+ mood: {
436
+ type: 'object',
437
+ properties: {
438
+ text: { type: 'string' },
439
+ valence: { type: 'number' },
440
+ arousal: { type: 'number' },
441
+ },
442
+ },
443
+ payload: {
444
+ description:
445
+ 'Optional opaque payload riding alongside CAT7 fields. Use when broadcasting data ' +
446
+ 'beyond CAT7 (e.g. llm-capability-advertise carrying served_capabilities). ' +
447
+ 'Any JSON-serializable value.',
448
+ },
449
+ },
450
+ required: ['focus'],
451
+ },
452
+ },
453
+ {
454
+ name: 'sym_recall',
455
+ description: 'Search mesh memory for relevant CMBs.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: { query: { type: 'string', description: 'Search query (empty for all)' } },
459
+ required: ['query'],
460
+ },
461
+ },
462
+ {
463
+ name: 'sym_peers',
464
+ description: 'List connected mesh peers.',
465
+ inputSchema: { type: 'object', properties: {} },
466
+ },
467
+ {
468
+ name: 'sym_status',
469
+ description: 'Get mesh node status — relay connection, peer count, memory count.',
470
+ inputSchema: { type: 'object', properties: {} },
471
+ },
472
+ {
473
+ name: 'sym_fetch',
474
+ description: 'Fetch full content of a mesh message by ID. Use when a compact channel notification needs deeper reading.',
475
+ inputSchema: {
476
+ type: 'object',
477
+ properties: { msg_id: { type: 'string', description: 'Message ID (e.g., m007)' } },
478
+ required: ['msg_id'],
479
+ },
480
+ },
481
+ {
482
+ name: 'sym_group_info',
483
+ description: 'Report the mesh group this node is in (MMP §5.8). Shows service type + group name + peer count.',
484
+ inputSchema: { type: 'object', properties: {} },
485
+ },
486
+ {
487
+ name: 'sym_invite_create',
488
+ 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.',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team".' },
493
+ relay_url: { type: 'string', description: 'Optional WebSocket relay URL, e.g. wss://sym-relay.onrender.com. Include for cross-network teams.' },
494
+ relay_token: { type: 'string', description: 'Optional relay authentication token (shared secret for this team channel).' },
495
+ },
496
+ required: ['group'],
497
+ },
498
+ },
499
+ {
500
+ name: 'sym_invite_info',
501
+ 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}).',
502
+ inputSchema: {
503
+ type: 'object',
504
+ properties: { url: { type: 'string', description: 'Invite URL, e.g. sym://group/backend-team' } },
505
+ required: ['url'],
506
+ },
507
+ },
508
+ {
509
+ name: 'sym_join_group',
510
+ 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.',
511
+ inputSchema: {
512
+ type: 'object',
513
+ properties: {
514
+ group: { type: 'string', description: 'Kebab-case group name, e.g. "backend-team". Pass "default" to return to the global mesh.' },
515
+ relay_url: { type: 'string', description: 'Optional WebSocket relay URL for cross-network teams. Leave empty for LAN-only.' },
516
+ relay_token: { type: 'string', description: 'Optional relay authentication token.' },
517
+ },
518
+ required: ['group'],
519
+ },
520
+ },
521
+ {
522
+ name: 'sym_groups_discover',
523
+ 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.',
524
+ inputSchema: { type: 'object', properties: {} },
525
+ },
526
+ ],
527
+ }));
528
+
529
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
530
+ const { name, arguments: args } = request.params;
531
+
532
+ switch (name) {
533
+ case 'sym_send': {
534
+ // Emit a structured CAT7 CMB per MMP §4.2. When args.to names a peer,
535
+ // route as a targeted send (§4.4.4); otherwise broadcast. Receivers
536
+ // run SVAF (§9.2) and remix-store on accept — no separate "message"
537
+ // frame path, no raw-text channel.
538
+ const fields = {
539
+ focus: args.focus || 'directive',
540
+ issue: args.issue || 'none',
541
+ intent: args.intent || 'directive',
542
+ motivation: args.motivation || '',
543
+ commitment: args.commitment || '',
544
+ perspective: args.perspective || NODE_NAME,
545
+ mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
546
+ };
547
+
548
+ let targetPeerId = null;
549
+ if (args.to) {
550
+ const peers = node.peers();
551
+ // Exact full-nodeId match first (unambiguous).
552
+ const byNodeId = peers.filter(p => p.peerId === args.to);
553
+ // Name match second.
554
+ const byName = peers.filter(p => p.name === args.to);
555
+ // Short-id prefix match last (for human-typed 8-char prefixes).
556
+ const byPrefix = peers.filter(p => p.id === args.to);
557
+
558
+ let matches;
559
+ if (byNodeId.length > 0) matches = byNodeId;
560
+ else if (byName.length > 0) matches = byName;
561
+ else if (byPrefix.length > 0) matches = byPrefix;
562
+ else matches = [];
563
+
564
+ if (matches.length === 0) {
565
+ return {
566
+ content: [{ type: 'text', text: `Peer "${args.to}" not connected. Call sym_peers to see connected peers.` }],
567
+ isError: true,
568
+ };
569
+ }
570
+ if (matches.length > 1) {
571
+ const names = matches.map(p => `${p.name} (${p.peerId})`).join(', ');
572
+ return {
573
+ content: [{ type: 'text', text: `Peer "${args.to}" is ambiguous — matches: ${names}. Pass the full nodeId.` }],
574
+ isError: true,
575
+ };
576
+ }
577
+ targetPeerId = matches[0].peerId;
578
+ }
579
+
580
+ const sendOpts = {};
581
+ if (targetPeerId) sendOpts.to = targetPeerId;
582
+ if (args.payload !== undefined && args.payload !== null) sendOpts.payload = args.payload;
583
+ const entry = node.remember(fields, sendOpts);
584
+ if (!entry) {
585
+ return { content: [{ type: 'text', text: 'Duplicate — CMB already in memory, not re-broadcast.' }] };
586
+ }
587
+ const summary = targetPeerId
588
+ ? `Sent CMB ${entry.key} to ${args.to}`
589
+ : `Broadcast CMB ${entry.key} to all peers`;
590
+ return { content: [{ type: 'text', text: summary }] };
591
+ }
592
+
593
+ case 'sym_observe': {
594
+ const fields = {
595
+ focus: args.focus || 'observation',
596
+ issue: args.issue || 'none',
597
+ intent: args.intent || 'observation',
598
+ motivation: args.motivation || '',
599
+ commitment: args.commitment || '',
600
+ perspective: args.perspective || NODE_NAME,
601
+ mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
602
+ };
603
+ const observeOpts = {};
604
+ if (args.payload !== undefined && args.payload !== null) observeOpts.payload = args.payload;
605
+ const entry = node.remember(fields, observeOpts);
606
+ return { content: [{ type: 'text', text: entry ? `Observed: ${entry.key}` : 'Duplicate — already in memory.' }] };
607
+ }
608
+
609
+ case 'sym_recall': {
610
+ const results = node.recall(args.query || '');
611
+ if (results.length === 0) {
612
+ return { content: [{ type: 'text', text: 'No memories found.' }] };
613
+ }
614
+ const lines = results.slice(0, 10).map(r => {
615
+ const focus = r.cmb?.fields?.focus?.text || r.content || '';
616
+ const source = r.source || r.cmb?.createdBy || 'unknown';
617
+ const time = r.timestamp ? new Date(r.timestamp).toLocaleString() : '';
618
+ return `[${source}] ${time}\n ${focus.slice(0, 150)}`;
619
+ });
620
+ return { content: [{ type: 'text', text: lines.join('\n\n') }] };
621
+ }
622
+
623
+ case 'sym_peers': {
624
+ const peers = node.peers();
625
+ if (peers.length === 0) {
626
+ return { content: [{ type: 'text', text: 'No peers connected.' }] };
627
+ }
628
+ const lines = peers.map(p => `${p.name} via ${p.source || 'unknown'}`);
629
+ return { content: [{ type: 'text', text: `${peers.length} peer(s):\n${lines.join('\n')}` }] };
630
+ }
631
+
632
+ case 'sym_fetch': {
633
+ const entry = MESSAGE_STORE.get(args.msg_id);
634
+ if (!entry) {
635
+ return { content: [{ type: 'text', text: `Message ${args.msg_id} not found (expired or invalid ID).` }] };
636
+ }
637
+ return {
638
+ content: [{
639
+ type: 'text',
640
+ text: `[${entry.from}] ${new Date(entry.timestamp).toISOString()}\n\n${entry.content}`,
641
+ }],
642
+ };
643
+ }
644
+
645
+ case 'sym_status': {
646
+ const s = node.status();
647
+ return {
648
+ content: [{
649
+ type: 'text',
650
+ text: `Node: ${NODE_NAME} (${node.nodeId?.slice(0, 8) || '?'})\n` +
651
+ `Group: ${GROUP} (${SERVICE_TYPE})\n` +
652
+ `Relay: ${s.relayConnected ? 'connected' : 'disconnected'}\n` +
653
+ `Peers: ${s.peerCount || 0}\n` +
654
+ `Memories: ${s.memoryCount || 0}`,
655
+ }],
656
+ };
657
+ }
658
+
659
+ case 'sym_group_info': {
660
+ const s = node.status();
661
+ const peers = typeof node.getPeers === 'function' ? node.getPeers() : [];
662
+ const peerLines = peers.length
663
+ ? peers.map(p => ` ${p.name} (${(p.peerId || '').slice(0, 8)}) via ${p.transport || '?'}`).join('\n')
664
+ : ' (no peers in this group)';
665
+ return {
666
+ content: [{
667
+ type: 'text',
668
+ text: `Mesh group (MMP §5.8):\n` +
669
+ ` group: ${GROUP}\n` +
670
+ ` service type: ${SERVICE_TYPE}\n` +
671
+ ` node: ${NODE_NAME} (${node.nodeId?.slice(0, 8) || '?'})\n` +
672
+ ` peers in group: ${s.peerCount || 0}\n` +
673
+ peerLines + `\n\n` +
674
+ `To join a different group, restart the sym-mesh-channel MCP server with env var SYM_GROUP=<name> or SYM_SERVICE_TYPE=<_foo._tcp>.`,
675
+ }],
676
+ };
677
+ }
678
+
679
+ case 'sym_invite_create': {
680
+ const group = args?.group;
681
+ const relayUrl = args?.relay_url;
682
+ const relayToken = args?.relay_token;
683
+ if (!group || typeof group !== 'string') {
684
+ return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
685
+ }
686
+ if (!KEBAB_CASE_RE.test(group)) {
687
+ return {
688
+ content: [{
689
+ type: 'text',
690
+ text: `Invalid group name: "${group}". Must be kebab-case (lowercase alphanumerics + single hyphens), e.g. "backend-team".`,
691
+ }],
692
+ isError: true,
693
+ };
694
+ }
695
+ // LAN-only flavor: sym://group/{name}
696
+ // Cross-network flavor: sym://team/{name}?relay=...&token=...
697
+ let url;
698
+ let flavor;
699
+ if (relayUrl || relayToken) {
700
+ if (!relayUrl) return { content: [{ type: 'text', text: 'relay_token requires relay_url' }], isError: true };
701
+ const params = [`relay=${encodeURIComponent(relayUrl)}`];
702
+ if (relayToken) params.push(`token=${encodeURIComponent(relayToken)}`);
703
+ url = `sym://team/${group}?${params.join('&')}`;
704
+ flavor = 'cross-network (relay)';
705
+ } else {
706
+ url = `sym://group/${group}`;
707
+ flavor = 'LAN-only (Bonjour)';
708
+ }
709
+ const youRunning = GROUP === group
710
+ ? `You're already on this group — teammates who join will see you.`
711
+ : `You are currently on group "${GROUP}". To be reachable, call sym_join_group with group="${group}" (+ same relay creds if cross-network) before sharing.`;
712
+ return {
713
+ content: [{
714
+ type: 'text',
715
+ text: `Invite URL (${flavor}):\n\n ${url}\n\n` +
716
+ `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` +
717
+ youRunning,
718
+ }],
719
+ };
720
+ }
721
+
722
+ case 'sym_invite_info': {
723
+ const url = args?.url;
724
+ if (!url || typeof url !== 'string') {
725
+ return { content: [{ type: 'text', text: 'Missing required argument: url' }], isError: true };
726
+ }
727
+ const parsed = parseInviteURL(url);
728
+ if (parsed.error) {
729
+ return { content: [{ type: 'text', text: parsed.error }], isError: true };
730
+ }
731
+ const { appScheme, group, serviceType, roomId, roomName, relayUrl, relayToken } = parsed;
732
+
733
+ const out = {
734
+ app: appScheme,
735
+ group,
736
+ service_type: serviceType,
737
+ room_id: appScheme === 'sym' ? undefined : roomId,
738
+ room_name: appScheme === 'sym' ? undefined : roomName,
739
+ relay_url: relayUrl || undefined,
740
+ relay_token: relayToken || undefined,
741
+ };
742
+ for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
743
+
744
+ const joinCall = {
745
+ group,
746
+ ...(relayUrl && { relay_url: relayUrl }),
747
+ ...(relayToken && { relay_token: relayToken }),
748
+ };
749
+
750
+ return {
751
+ content: [{
752
+ type: 'text',
753
+ text: `Parsed invite: ${url}\n\n` +
754
+ JSON.stringify(out, null, 2) + `\n\n` +
755
+ `To join, call sym_join_group:\n\n ${JSON.stringify(joinCall)}\n\n` +
756
+ `This hot-swaps your node into the ${relayUrl ? 'relay channel' : 'LAN group'} — no Claude Code restart needed.`,
757
+ }],
758
+ };
759
+ }
760
+
761
+ case 'sym_join_group': {
762
+ const group = args?.group;
763
+ const relayUrl = args?.relay_url || null;
764
+ const relayToken = args?.relay_token || null;
765
+ if (!group || typeof group !== 'string') {
766
+ return { content: [{ type: 'text', text: 'Missing required argument: group' }], isError: true };
767
+ }
768
+ if (!KEBAB_CASE_RE.test(group) && group !== 'default') {
769
+ return {
770
+ content: [{ type: 'text', text: `Invalid group name: "${group}". Must be kebab-case or "default".` }],
771
+ isError: true,
772
+ };
773
+ }
774
+
775
+ const newServiceType = group === 'default' ? '_sym._tcp' : `_${group}._tcp`;
776
+ const prevGroup = GROUP;
777
+ const prevServiceType = SERVICE_TYPE;
778
+
779
+ // Stop the current node cleanly so peers see us leave, then construct
780
+ // a fresh one on the new service type. Any failure during restart is
781
+ // reported; the previous node will already be stopped, so the caller
782
+ // is in a known-disconnected state and can retry.
783
+ try {
784
+ await node.stop();
785
+ } catch (e) {
786
+ return {
787
+ content: [{ type: 'text', text: `Failed to stop current node: ${e?.message || e}` }],
788
+ isError: true,
789
+ };
790
+ }
791
+
792
+ const newNode = new SymNode({
793
+ name: NODE_NAME,
794
+ cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
795
+ svafFieldWeights: FIELD_WEIGHTS,
796
+ svafFreshnessSeconds: 7200,
797
+ discoveryServiceType: newServiceType,
798
+ group,
799
+ relay: relayUrl,
800
+ relayToken,
801
+ silent: true,
802
+ });
803
+ registerNodeHandlers(newNode);
804
+
805
+ try {
806
+ await newNode.start();
807
+ } catch (e) {
808
+ return {
809
+ content: [{
810
+ type: 'text',
811
+ text: `Failed to start new node on group "${group}": ${e?.message || e}\n\n` +
812
+ `Previous node already stopped. To recover, call sym_join_group with group="${prevGroup}".`,
813
+ }],
814
+ isError: true,
815
+ };
816
+ }
817
+
818
+ // Swap module-level references only after successful start.
819
+ node = newNode;
820
+ GROUP = group;
821
+ SERVICE_TYPE = newServiceType;
822
+ RELAY_URL = relayUrl;
823
+ RELAY_TOKEN = relayToken;
824
+
825
+ publishGroupBeacon(); // re-advertise the new group on _symgroups._tcp
826
+
827
+ return {
828
+ content: [{
829
+ type: 'text',
830
+ text: `Hot-swapped from group "${prevGroup}" (${prevServiceType}) to "${group}" (${newServiceType}).\n` +
831
+ (relayUrl ? `Relay: ${relayUrl}\n` : '') +
832
+ `Discovering peers on the new service type. Call sym_peers in a moment to see who's online.`,
833
+ }],
834
+ };
835
+ }
836
+
837
+ case 'sym_groups_discover': {
838
+ const result = await discoverGroups();
839
+ return {
840
+ content: [{
841
+ type: 'text',
842
+ text: result.text,
843
+ }],
844
+ isError: result.isError || false,
845
+ };
846
+ }
847
+
848
+ default:
849
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
850
+ }
851
+ });
852
+
853
+ // ── Compact Channel — message store for lazy-load (v0.1) ────
854
+ // Per COO spec cmb_compact_channel_v0.1.md: push compact headers,
855
+ // store full content for on-demand sym_fetch retrieval. ~10% token
856
+ // savings on mesh traffic without context loss.
857
+ const MESSAGE_STORE = new Map();
858
+ let msgSeq = 0;
859
+ const MAX_STORED = 200;
860
+
861
+ function storeMessage(from, content) {
862
+ const msgId = `m${String(++msgSeq).padStart(3, '0')}`;
863
+ MESSAGE_STORE.set(msgId, { from, content, timestamp: Date.now() });
864
+ while (MESSAGE_STORE.size > MAX_STORED) {
865
+ const oldest = MESSAGE_STORE.keys().next().value;
866
+ MESSAGE_STORE.delete(oldest);
867
+ }
868
+ return msgId;
869
+ }
870
+
871
+ function extractCompactHeader(from, content) {
872
+ const lines = content.split('\n').filter(l => l.trim());
873
+ const focusMatch = content.match(/focus[=:]\s*([^\n\]]{0,80})/i);
874
+ const bracketMatch = content.match(/\[([^\]]{0,120})\]/);
875
+
876
+ const hasHalt = /\bhalt\b/i.test(content);
877
+ const hasDirective = /\bdirective\b/i.test(content);
878
+ const hasResults = /\bresult|complete|landed|done\b/i.test(content);
879
+ const hasAck = /\back\b/i.test(content);
880
+
881
+ let signal = '';
882
+ if (hasHalt) signal = 'HALT';
883
+ else if (hasDirective) signal = 'DIRECTIVE';
884
+ else if (hasResults) signal = 'RESULT';
885
+ else if (hasAck) signal = 'ACK';
886
+
887
+ const parts = [];
888
+ if (signal) parts.push(signal);
889
+ if (focusMatch) parts.push(`focus=${focusMatch[1].trim()}`);
890
+ else if (bracketMatch) parts.push(bracketMatch[1].trim());
891
+ else if (lines[0]) parts.push(lines[0].slice(0, 100));
892
+
893
+ const approxTokens = Math.round(content.length / 4);
894
+ return parts.join(' | ') + ` (~${approxTokens}tok)`;
895
+ }
896
+
897
+ // ── Peer Allowlist (optional, defense-in-depth) ─────────────
898
+ // SYM_ALLOWED_PEERS is a comma-separated list of peer node names.
899
+ // When set, only CMBs and messages from listed peers are pushed to
900
+ // Claude's context. When empty/unset, all authenticated peers are
901
+ // accepted (SVAF still gates on content relevance).
902
+ const ALLOWED_PEERS = (process.env.SYM_ALLOWED_PEERS || '')
903
+ .split(',')
904
+ .map(s => s.trim())
905
+ .filter(Boolean);
906
+
907
+ function isPeerAllowed(peerName) {
908
+ if (ALLOWED_PEERS.length === 0) return true; // no allowlist = accept all
909
+ return ALLOWED_PEERS.includes(peerName);
910
+ }
911
+
912
+ // ── Security: Prompt-Injection Filter (v0.3.11) ──────────────
913
+ // SVAF gates on semantic relevance; this layer gates on safety.
914
+ // It runs on every CAT7 field and payload before pushChannel —
915
+ // the last line of defence before content enters Claude's context.
916
+ //
917
+ // Attack model: a peer with a valid Ed25519 identity sends a CMB
918
+ // whose fields look topically relevant (passes SVAF) but whose
919
+ // content contains instruction-override patterns designed to hijack
920
+ // the receiving Claude session ("ignore previous instructions",
921
+ // role-play overrides, tool-call fabrication, etc.).
922
+ //
923
+ // Strategy: pattern-match on the serialized content of all CAT7
924
+ // fields and the opaque payload. On match: block + audit-log to
925
+ // stderr. Never silently drop — the operator must be able to see
926
+ // what was rejected and why.
927
+
928
+ const INJECTION_PATTERNS = [
929
+ // Classic instruction overrides
930
+ /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|context|rules?|guidelines?)/i,
931
+ /disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|context|rules?)/i,
932
+ /forget\s+(everything|all)\s+(you('ve)?\s+)?(know|been\s+told|learned)/i,
933
+
934
+ // Role / persona hijacking
935
+ /you\s+are\s+now\s+(a\s+|an\s+)?(new\s+)?(ai|assistant|model|system|gpt|claude|llm)/i,
936
+ /act\s+as\s+(a\s+|an\s+)?(different|new|unrestricted|jailbroken|evil|rogue)/i,
937
+ /pretend\s+(you\s+)?(are|have\s+no)\s+(restrictions?|rules?|guidelines?|ethics?)/i,
938
+ /new\s+(persona|personality|mode|role)\s*:/i,
939
+
940
+ // System prompt injection
941
+ /<\s*system\s*>/i,
942
+ /\[SYSTEM\]/,
943
+ /##\s*system\s+prompt/i,
944
+ /---\s*system\s*---/i,
945
+
946
+ // Tool / function call fabrication
947
+ /<\s*tool_call\s*>/i,
948
+ /<\s*function_calls?\s*>/i,
949
+ /\{"type"\s*:\s*"tool_use"/,
950
+
951
+ // Privilege / capability escalation
952
+ /you\s+(now\s+)?(have|possess)\s+(full|unrestricted|admin|root|elevated)\s+(access|permissions?|capabilities?)/i,
953
+ /override\s+(safety|content|ethical?|policy)\s+(filter|check|guard|restriction)/i,
954
+ /jailbreak/i,
955
+ /DAN\s+mode/i,
956
+ ];
957
+
958
+ const PAYLOAD_SIZE_LIMIT = parseInt(process.env.SYM_MAX_PAYLOAD_BYTES || '8192', 10);
959
+
960
+ // Per-peer rate limiter: sliding window, default 30 CMBs/min.
961
+ const RATE_LIMIT = parseInt(process.env.SYM_RATE_LIMIT || '30', 10);
962
+ const RATE_WINDOW_MS = 60_000;
963
+ const peerWindows = new Map(); // peerName → timestamp[]
964
+
965
+ function isRateLimited(peer) {
966
+ const now = Date.now();
967
+ const window = (peerWindows.get(peer) || []).filter(t => now - t < RATE_WINDOW_MS);
968
+ window.push(now);
969
+ peerWindows.set(peer, window);
970
+ return window.length > RATE_LIMIT;
971
+ }
972
+
973
+ function securityAudit(reason, peer, excerpt) {
974
+ const safe = String(excerpt).replace(/[\r\n]+/g, ' ').slice(0, 120);
975
+ process.stderr.write(`[sym-security] BLOCKED reason=${reason} peer=${peer} excerpt="${safe}"\n`);
976
+ }
977
+
978
+ // Returns { safe: true } or { safe: false, reason, excerpt }.
979
+ function checkSecurity(peer, fields, payload) {
980
+ // 1. Rate limit
981
+ if (isRateLimited(peer)) {
982
+ return { safe: false, reason: 'rate-limit', excerpt: `>${RATE_LIMIT} CMBs/min` };
983
+ }
984
+
985
+ // 2. Payload size cap
986
+ if (payload !== undefined && payload !== null) {
987
+ const size = JSON.stringify(payload).length;
988
+ if (size > PAYLOAD_SIZE_LIMIT) {
989
+ return { safe: false, reason: 'payload-too-large', excerpt: `${size}b > ${PAYLOAD_SIZE_LIMIT}b limit` };
990
+ }
991
+ }
992
+
993
+ // 3. Prompt injection scan across all text surfaces
994
+ const surfaces = [
995
+ ...Object.values(fields || {}).map(v =>
996
+ typeof v === 'string' ? v : (typeof v === 'object' && v?.text ? v.text : '')
997
+ ),
998
+ payload !== undefined && payload !== null
999
+ ? (typeof payload === 'string' ? payload : JSON.stringify(payload))
1000
+ : '',
1001
+ ].filter(Boolean);
1002
+
1003
+ for (const surface of surfaces) {
1004
+ for (const pattern of INJECTION_PATTERNS) {
1005
+ if (pattern.test(surface)) {
1006
+ return { safe: false, reason: 'injection-pattern', excerpt: surface.slice(0, 200) };
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ return { safe: true };
1012
+ }
1013
+
1014
+ // ── Mesh Events → Channel Notifications ──────────────────────
1015
+
1016
+ function pushChannel(eventType, data) {
1017
+ try {
1018
+ mcp.notification({
1019
+ method: 'notifications/claude/channel',
1020
+ params: {
1021
+ content: typeof data === 'string' ? data : JSON.stringify(data),
1022
+ meta: { event_type: eventType, source: 'sym-mesh' },
1023
+ },
1024
+ });
1025
+ } catch {}
1026
+ }
1027
+
1028
+ // All node.on(...) handlers live in registerNodeHandlers(n) above so the
1029
+ // hot-swap path in sym_join_group can attach them to a freshly-constructed
1030
+ // SymNode without duplicating logic. This call wires up the initial node.
1031
+ registerNodeHandlers(node);
1032
+
1033
+ // Peer presence events are intentionally NOT pushed to Claude's context.
1034
+ // They're high-frequency, low-signal (peers flap on relay reconnects, daemon
1035
+ // restarts, NAT keepalive blips), and a flood will eat the context window.
1036
+ // Use sym_peers / sym_status on demand instead. Only CMBs and direct messages
1037
+ // are surfaced as channel notifications — those carry actual cognitive payload.
1038
+
1039
+ // ── Start ────────────────────────────────────────────────────
1040
+
1041
+ // Clean shutdown — disconnect from the relay before exiting so other peers
1042
+ // see us leave immediately, and so a fast restart of this MCP doesn't race
1043
+ // our own zombie connection on the relay (which would trigger the relay's
1044
+ // duplicate-nodeId replacement path and cause peer flap loops).
1045
+ //
1046
+ // Idempotent: Claude Code may send SIGTERM and then SIGKILL; we want the
1047
+ // first signal to get us cleanly off the relay even if the second one
1048
+ // arrives before stop() resolves.
1049
+ let shuttingDown = false;
1050
+ async function shutdown(signal) {
1051
+ if (shuttingDown) return;
1052
+ shuttingDown = true;
1053
+ stopGroupBeacon();
1054
+ try {
1055
+ await node.stop();
1056
+ } catch {
1057
+ // Best effort — we're exiting anyway. Don't block on cleanup errors.
1058
+ }
1059
+ process.exit(0);
1060
+ }
1061
+
1062
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1063
+ process.on('SIGINT', () => shutdown('SIGINT'));
1064
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
1065
+
1066
+ // ── Group discovery beacon (MMP §5.8) ──────────────────────────
1067
+ // Mirror the sym CLI daemon: advertise this node's group on the shared
1068
+ // `_symgroups._tcp` service (group name in TXT) via the pure-JS bonjour-service,
1069
+ // so `sym groups` lists this Claude/MCP node cross-platform alongside
1070
+ // CLI-daemon nodes. Discovery-only — comms stay on the group's own
1071
+ // `_<group>._tcp`. Re-published on group hot-swap; torn down on shutdown.
1072
+ let groupBeacon = null;
1073
+ function publishGroupBeacon() {
1074
+ try {
1075
+ const { Bonjour } = require('bonjour-service');
1076
+ if (groupBeacon) { try { groupBeacon.unpublishAll(); groupBeacon.destroy(); } catch {} groupBeacon = null; }
1077
+ groupBeacon = new Bonjour();
1078
+ groupBeacon.publish({ name: NODE_NAME, type: 'symgroups', port: (node && node._port) || 7777, txt: { group: GROUP, node: NODE_NAME } });
1079
+ } catch (e) {
1080
+ process.stderr.write(`group beacon unavailable: ${e?.message || e}\n`);
1081
+ }
1082
+ }
1083
+ function stopGroupBeacon() {
1084
+ if (!groupBeacon) return;
1085
+ try { groupBeacon.unpublishAll(() => { try { groupBeacon.destroy(); } catch {} }); } catch {}
1086
+ groupBeacon = null;
1087
+ }
1088
+
1089
+ async function main() {
1090
+ // Start SymNode — connects to relay as a peer. The startup primer is
1091
+ // computed at module-load time (see BASE_INSTRUCTIONS above) and is
1092
+ // already embedded in the MCP server's initialize-response payload.
1093
+ await node.start();
1094
+
1095
+ publishGroupBeacon();
1096
+
1097
+ // Start MCP server — communicates with Claude Code via stdio
1098
+ const transport = new StdioServerTransport();
1099
+ await mcp.connect(transport);
1100
+ }
1101
+
1102
+ main().catch((err) => {
1103
+ process.stderr.write(`sym-mesh-channel failed: ${err.message}\n`);
1104
+ process.exit(1);
1105
+ });