@sym-bot/mesh-channel 0.3.3 → 0.3.5

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.
@@ -18,7 +18,7 @@
18
18
  "name": "Hongwei Xu",
19
19
  "email": "hongwei@sym.bot"
20
20
  },
21
- "homepage": "https://sym.bot/spec/mmp",
21
+ "homepage": "https://meshcognition.org/spec/mmp",
22
22
  "repository": "https://github.com/sym-bot/sym-mesh-channel",
23
23
  "license": "Apache-2.0",
24
24
  "keywords": [
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "sym-mesh-channel",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Real-time Claude-to-Claude mesh. Agent-to-agent cognitive signals over Bonjour LAN or WebSocket relay.",
5
5
  "author": {
6
6
  "name": "Hongwei Xu",
7
7
  "email": "hongwei@sym.bot",
8
8
  "url": "https://sym.bot"
9
9
  },
10
- "homepage": "https://sym.bot/spec/mmp",
10
+ "homepage": "https://meshcognition.org/spec/mmp",
11
11
  "repository": "https://github.com/sym-bot/sym-mesh-channel",
12
12
  "license": "Apache-2.0",
13
13
  "keywords": ["mesh", "p2p", "mcp", "channel", "agents", "multi-agent", "bonjour", "cognitive", "svaf", "mmp"],
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5
4
+
5
+ ### Added
6
+
7
+ - **Opaque payload on `sym_send` / `sym_observe`.** Both tools accept
8
+ an optional `payload` argument carrying data beyond CAT7 — any
9
+ JSON-serializable value. Forwarded to `SymNode.remember(fields, {
10
+ payload, … })` (requires `@sym-bot/sym` ≥ 0.5.8) and rides the wire
11
+ frame to peers. Used by substrate-level protocols that need to carry
12
+ structured data alongside CAT7 (e.g. LLM request/response, where the
13
+ prompt + request_id ride in `payload` rather than getting smuggled
14
+ through `motivation`).
15
+ - **Channel notifications surface payload-bearing CMBs.** When an
16
+ incoming peer CMB carries `cmb.payload`, the header gains a
17
+ `[+payload Nb]` indicator and the body stored by `sym_fetch`
18
+ includes a `---PAYLOAD---` section with the serialized payload.
19
+ Receivers learn from the header that there's structured data beyond
20
+ CAT7 and call `sym_fetch` to consume it.
21
+ - Base MCP instructions now teach agents to recognise the
22
+ `[+payload Nb]` header and to pass structured responses via the
23
+ `payload` argument when emitting substrate-level CMBs.
24
+
25
+ ### Compatibility
26
+
27
+ - Omitting `payload` produces a v0.3.4-shaped CAT7 CMB byte-for-byte.
28
+ - Old peers (without `cmb.payload`) surface unchanged headers — no
29
+ `[+payload …]` indicator, no PAYLOAD section in the body.
30
+
31
+ ## 0.3.4
32
+
33
+ ### Added
34
+
35
+ - **`SYM_GROUP` is now first-class in the installer.** `init` accepts a
36
+ `--group <name>` flag and reads the `SYM_GROUP` env var; both paths
37
+ persist the chosen group into the `~/.claude.json` (or project
38
+ `.mcp.json`) env block so every Claude Code launch auto-joins the
39
+ named group instead of the global `_sym._tcp` mesh.
40
+
41
+ Resolution order is `--force`-aware:
42
+ - With `--force` and an explicit `--group`/`SYM_GROUP`: flag/env wins
43
+ (one-command group switch on a live entry).
44
+ - Without `--force`, or with `--force` but no explicit value:
45
+ preserved value from any existing entry > explicit > none (omit).
46
+
47
+ `--force --group default` (or `SYM_GROUP=default`) is the explicit
48
+ escape hatch to revert a node from a named group back to the global
49
+ mesh — removes `SYM_GROUP` from the env block entirely rather than
50
+ writing the literal string "default".
51
+
52
+ Both `--group` and `SYM_GROUP` env values are validated against the
53
+ same kebab-case regex; malformed values exit with a clear error
54
+ before any file write.
55
+
56
+ - **`doctor` now reports the persisted group per entry** and warns when
57
+ user-global and project-scoped entries disagree on `SYM_GROUP`.
58
+ Group-mismatch is the most common cause of "peers never appear in
59
+ `sym_peers`" with no other failure signal — surfacing it inline saves
60
+ the diagnostic walk that motivated this release.
61
+
62
+ - **README** gains a "Persisting your group across restarts" subsection
63
+ under Team mesh groups, plus a troubleshooting entry covering the
64
+ group-mismatch failure mode. Quick-start shows the `--group` flag.
65
+
66
+ ### Fixed
67
+
68
+ - **Stale-entry heal preserves `SYM_GROUP` alongside `SYM_NODE_NAME`.**
69
+ Previously, healing a stale `claude-sym-mesh` entry (args[0] points at
70
+ a missing server.js) silently dropped any persisted `SYM_GROUP`,
71
+ reverting the node to the default mesh on next launch and stranding
72
+ teammates who stayed in the named group. The heal path now copies
73
+ both fields from the prior entry into the rewrite.
74
+
75
+ Same fix applied to project-scoped entry healing under
76
+ `claudeJson.projects[<path>].mcpServers`.
77
+
78
+ ### Why this matters
79
+
80
+ Before 0.3.4, the only way to persist a group was to hand-edit
81
+ `~/.claude.json`. The README pitched `sym_join_group` as the team-mesh
82
+ UX, but that tool is runtime-only — the next Claude Code launch reverted
83
+ the node to the default mesh, peer count dropped to zero, and the user
84
+ saw no diagnostic signal. The 2026-05-02 SYM.BOT incident (CMO in
85
+ `default`, COO in `sym-bot-team`, ~24h of silent duplex outage) traced
86
+ directly to this gap.
87
+
3
88
  ## 0.3.3
4
89
 
5
90
  ### Fixed
package/README.md CHANGED
@@ -8,7 +8,7 @@ npm install -g @sym-bot/mesh-channel && claude
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/@sym-bot/mesh-channel)](https://www.npmjs.com/package/@sym-bot/mesh-channel)
10
10
  [![Plugin Directory](https://img.shields.io/badge/Anthropic_Plugin_Directory-approved-success)](https://claude.ai/settings/plugins/submit)
11
- [![MMP Spec](https://img.shields.io/badge/protocol-MMP_v0.2.3-purple)](https://sym.bot/spec/mmp)
11
+ [![MMP Spec](https://img.shields.io/badge/protocol-MMP_v1.0-orange)](https://meshcognition.org/spec/mmp)
12
12
  [![SVAF arXiv](https://img.shields.io/badge/arXiv-2604.03955-b31b1b.svg)](https://arxiv.org/abs/2604.03955)
13
13
  [![MMP arXiv](https://img.shields.io/badge/arXiv-2604.19540-b31b1b.svg)](https://arxiv.org/abs/2604.19540)
14
14
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -28,7 +28,7 @@ Verified working: Mac ↔ Windows on the same wifi, pure Bonjour, no relay, no t
28
28
 
29
29
  - **Small engineering teams** whose Claude Code sessions currently copy-paste findings over Slack. Replace that loop with direct agent-to-agent coordination.
30
30
  - **Distributed teams** running Claude Code across offices, home networks, and coffee shops. Isolated team channels via mesh groups, no shared server.
31
- - **Multi-agent developers** prototyping cognitive architectures — `sym-mesh-channel` is the reference Claude Code host for the [Mesh Memory Protocol](https://sym.bot/spec/mmp).
31
+ - **Multi-agent developers** prototyping cognitive architectures — `sym-mesh-channel` is the reference Claude Code host for the [Mesh Memory Protocol](https://meshcognition.org/spec/mmp).
32
32
  - **Not for:** single-user Claude sessions that don't need to coordinate with anyone. You'd get MCP tools but nothing to coordinate with.
33
33
 
34
34
  ## Quick start
@@ -63,6 +63,14 @@ To customise your mesh identity, set `SYM_NODE_NAME` before running init:
63
63
  SYM_NODE_NAME=claude-alice npx @sym-bot/mesh-channel init --force
64
64
  ```
65
65
 
66
+ To pin this node into a named team group at install time so the membership survives Claude Code restarts, pass `--group <name>` (or set `SYM_GROUP=<name>` in the environment):
67
+
68
+ ```bash
69
+ SYM_NODE_NAME=claude-alice npx @sym-bot/mesh-channel init --force --group backend-team
70
+ ```
71
+
72
+ Without `--group`, the node joins the global `_sym._tcp` mesh on every launch — runtime hot-swaps via `sym_join_group` only last for the current session and revert on restart. See [Team mesh groups](#team-mesh-groups) for the full story.
73
+
66
74
  **Real-time push is a separate upgrade.** The command above gives you all 11 MCP tools immediately. To additionally have peer messages *appear in Claude's context mid-turn without a tool call* (the "Claude thinks with the mesh" experience), launch Claude Code with the Channels flag:
67
75
 
68
76
  ```bash
@@ -121,7 +129,37 @@ Parsed invite: sym://group/backend-team
121
129
  Hot-swapped from group "default" to "backend-team".
122
130
  ```
123
131
 
124
- No restart. No `~/.claude.json` editing. Teammates on the same LAN now see each other; `backend-team` and `frontend-team` live in isolated mDNS spaces.
132
+ No restart needed for the current session. Teammates on the same LAN now see each other; `backend-team` and `frontend-team` live in isolated mDNS spaces.
133
+
134
+ > **`sym_join_group` is runtime-only.** On the next Claude Code launch, the node restarts from its `~/.claude.json` config — if `SYM_GROUP` isn't persisted there, it reverts to the global mesh and your teammates' peer count silently drops to zero. Persist your membership before closing the session (see below).
135
+
136
+ ### Persisting your group across restarts
137
+
138
+ The hot-swap above is convenient for trying a group, but a real team setup needs the group baked into the MCP env block so every Claude Code launch joins automatically. Two paths:
139
+
140
+ ```bash
141
+ # (a) Reinstall with the --group flag — preserves SYM_NODE_NAME from the
142
+ # existing entry, adds SYM_GROUP, atomically rewrites ~/.claude.json:
143
+ npx @sym-bot/mesh-channel init --force --group backend-team
144
+
145
+ # (b) For a project-scoped install (multi-project laptop):
146
+ cd path/to/project
147
+ SYM_NODE_NAME=claude-myproject npx @sym-bot/mesh-channel init --project --group backend-team
148
+ ```
149
+
150
+ After either path, restart Claude Code once; subsequent sessions auto-join the group. To switch groups on a live entry use `--force` together with `--group`:
151
+
152
+ ```bash
153
+ # Switch from one named group to another (one command):
154
+ npx @sym-bot/mesh-channel init --force --group new-team
155
+
156
+ # Revert to the global mesh (escape hatch):
157
+ npx @sym-bot/mesh-channel init --force --group default
158
+ ```
159
+
160
+ Without `--force`, an existing persisted `SYM_GROUP` always wins over a flag — the heal path's job is to never lose user state on a routine reinstall. With `--force`, the flag is the explicit override and takes precedence.
161
+
162
+ Run `npx @sym-bot/mesh-channel doctor` any time to see which group each `claude-sym-mesh` entry is configured for. The doctor flags group mismatches across user-global and project-scoped entries — the most common cause of "we're on the same wifi but my teammate's node never appears in `sym_peers`".
125
163
 
126
164
  ### Distributed team (via relay)
127
165
 
@@ -167,7 +205,7 @@ sym-mesh-channel ←—— Bonjour mDNS ——→ sym-mesh-channel
167
205
  The plugin composes two open specs:
168
206
 
169
207
  - **[Claude Code Channels](https://code.claude.com/docs/en/mcp)** (Anthropic, 2026-03-20) — an MCP capability that lets servers push events directly into Claude's conversation context mid-turn via `notifications/claude/channel`. Anthropic built it for the Telegram/Discord/iMessage integrations. We use it for agent-to-agent cognitive coupling.
170
- - **[MMP — the Mesh Memory Protocol](https://sym.bot/spec/mmp)** — defines *what* gets pushed: typed seven-field cognitive bundles (CAT7: focus, issue, intent, motivation, commitment, perspective, mood), how receivers gate incoming signals ([SVAF](https://arxiv.org/abs/2604.03955)), and how peers maintain identity without a central orchestrator.
208
+ - **[MMP — the Mesh Memory Protocol](https://meshcognition.org/spec/mmp)** — defines *what* gets pushed: typed seven-field cognitive bundles (CAT7: focus, issue, intent, motivation, commitment, perspective, mood), how receivers gate incoming signals ([SVAF](https://arxiv.org/abs/2604.03955)), and how peers maintain identity without a central orchestrator.
171
209
 
172
210
  **What happens on each message.** When a peer broadcasts a cognitive memory block (CMB), the local SymNode evaluates it via SVAF — Symbolic-Vector Attention Fusion, a receiver-side relevance gate that rejects low-signal messages before they reach Claude's context. If accepted, the MCP server fires a `notifications/claude/channel` notification to Claude Code, which surfaces it as a `<channel>` block in the conversation. Claude sees it, can react, and can broadcast back via `sym_send` or `sym_observe`. No polling. No tool calls. The mesh thinks together.
173
211
 
@@ -258,6 +296,19 @@ npx -y @sym-bot/mesh-channel init
258
296
 
259
297
  `init` preserves each entry's `SYM_NODE_NAME` so your mesh identity doesn't drift. Live entries are left alone; `--force` is only needed to overwrite a live entry deliberately. Restart Claude Code after healing — MCP servers are spawned at session start and won't pick up config changes mid-session.
260
298
 
299
+ ### Peers connect (Claude Code starts cleanly) but never appear in `sym_peers`
300
+
301
+ Almost always a **mesh group mismatch** — Bonjour scopes discovery by service type (`_<group>._tcp`), so `default` and `backend-team` nodes on the same wifi are invisible to each other. Two diagnostics:
302
+
303
+ ```
304
+ > sym_status # shows: Group: <name> (<service-type>)
305
+ > sym_groups_discover # shows every group currently advertising on the LAN
306
+ ```
307
+
308
+ If your teammate's node is on a different group, align via `sym_join_group` (this session) and persist via `init --group <name>` (future sessions). Run `npx @sym-bot/mesh-channel doctor` to confirm the persisted group on every entry — the doctor explicitly flags mismatches across user-global and project-scoped configs.
309
+
310
+ This is the failure pattern where every other indicator looks healthy: `sym_status` says `Peers: 0` but the underlying SymNode is fine, the mDNS service is registered, and the relay (if any) is connected — they're just announced on a service type your peer isn't browsing.
311
+
261
312
  ### Peers don't see each other on the same wifi
262
313
 
263
314
  Check Bonjour is running:
@@ -301,7 +352,7 @@ Use this if you prefer the plugin surface for install and update management. The
301
352
 
302
353
  - [SVAF paper](https://arxiv.org/abs/2604.03955) — Xu, 2026. *Symbolic-Vector Attention Fusion for Collective Intelligence*. arXiv:2604.03955.
303
354
  - [MMP paper](https://arxiv.org/abs/2604.19540) — Xu, 2026. *Mesh Memory Protocol: Semantic Infrastructure for Multi-Agent LLM Systems*. arXiv:2604.19540.
304
- - [MMP spec v0.2.3](https://sym.bot/spec/mmp) — Mesh Memory Protocol specification (canonical web version).
355
+ - [MMP spec v1.0](https://meshcognition.org/spec/mmp) — Mesh Memory Protocol specification (canonical web version).
305
356
  - [sym-swift](https://github.com/sym-bot/sym-swift) — iOS/macOS SDK implementing the same protocol.
306
357
  - [sym-relay](https://github.com/sym-bot/sym-relay) — WebSocket relay for cross-network mesh.
307
358
 
package/SECURITY.md CHANGED
@@ -85,5 +85,5 @@ exit code 2 rather than competing for the identity.
85
85
 
86
86
  ## References
87
87
 
88
- - [MMP v0.2.2 Specification](https://sym.bot/spec/mmp) — Sections 5 (Connection), 8 (CAT7), 9 (SVAF)
88
+ - [MMP v1.0 Specification](https://meshcognition.org/spec/mmp) — Sections 5 (Connection), 8 (CAT7), 9 (SVAF)
89
89
  - [SVAF Paper](https://arxiv.org/abs/2604.03955) — Xu, 2026
package/bin/install.js CHANGED
@@ -46,11 +46,37 @@ const isPostinstall = args.includes('--postinstall');
46
46
  const isProject = args.includes('--project');
47
47
  const cmd = args.find((a) => !a.startsWith('--')) || 'init';
48
48
 
49
+ // --group <name>: persist a SYM_GROUP env entry into the written .mcp.json /
50
+ // ~/.claude.json so the node joins that group on every Claude Code launch.
51
+ // Without this flag, the env block omits SYM_GROUP and the node falls back
52
+ // to the default _sym._tcp mesh on startup. Runtime sym_join_group hot-swaps
53
+ // only last for the current session — without persistence, peers in named
54
+ // groups silently revert to default and become invisible to teammates.
55
+ const groupArgIdx = args.indexOf('--group');
56
+ const groupArg = groupArgIdx !== -1 ? args[groupArgIdx + 1] : null;
57
+
49
58
  if (cmd !== 'init' && cmd !== 'doctor') {
50
- process.stderr.write(`Unknown command: ${cmd}\nUsage: sym-mesh-channel init [--project] [--force]\n sym-mesh-channel doctor\n`);
59
+ process.stderr.write(`Unknown command: ${cmd}\nUsage: sym-mesh-channel init [--project] [--force] [--group <name>]\n sym-mesh-channel doctor\n`);
51
60
  process.exit(1);
52
61
  }
53
62
 
63
+ const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
+ function validateGroupValue(value, source) {
65
+ if (!value) return;
66
+ if (value === 'default') return;
67
+ if (!KEBAB_CASE_RE.test(value)) {
68
+ process.stderr.write(`ERROR: ${source} "${value}" must be kebab-case (e.g. backend-team) or "default".\n`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+ validateGroupValue(groupArg, '--group');
73
+ // Apply the same gate to the env-var path. Pre-0.3.4-followup, a malformed
74
+ // SYM_GROUP=' ' or SYM_GROUP=Backend_Team value flowed through unvalidated
75
+ // and got written into the .mcp.json env block as-is, producing an mDNS
76
+ // service type the SymNode would silently fail to register on. Now both
77
+ // inputs share the validator with the same error message shape.
78
+ validateGroupValue(process.env.SYM_GROUP, 'SYM_GROUP');
79
+
54
80
  // ── isStaleEntry: a claude-sym-mesh entry whose server.js path is gone ──
55
81
  // Returns true when the entry exists but its args[0] path does not resolve
56
82
  // to a file on disk. Such an entry can never spawn the MCP server — every
@@ -74,6 +100,17 @@ function preserveNodeName(entry) {
74
100
  return n || null;
75
101
  }
76
102
 
103
+ // preserveGroup: return the SYM_GROUP from an existing entry's env so
104
+ // rewrites keep the mesh group. Same shape as preserveNodeName — without
105
+ // this, healing a stale entry would drop a previously-persisted group
106
+ // and silently downgrade the node to the default _sym._tcp mesh,
107
+ // stranding teammates who stay in the named group.
108
+ function preserveGroup(entry) {
109
+ if (!entry || !entry.env || typeof entry.env.SYM_GROUP !== 'string') return null;
110
+ const g = entry.env.SYM_GROUP.trim();
111
+ return g || null;
112
+ }
113
+
77
114
  // --postinstall always runs global install (npm postinstall runs from
78
115
  // npm's staging directory, not the user's project dir). If both flags
79
116
  // are passed, the --project flag is ignored during postinstall.
@@ -89,6 +126,39 @@ const defaultNodeName = `claude-${os.hostname().toLowerCase().replace(/[^a-z0-9-
89
126
  // SYM_NODE_NAME from env wins over default
90
127
  const nodeName = process.env.SYM_NODE_NAME || defaultNodeName;
91
128
 
129
+ // Capture the user's *explicit* group intent for this install, distinct
130
+ // from "user didn't ask, use existing or default":
131
+ // null → user didn't pass --group or SYM_GROUP
132
+ // 'default' → user explicitly wants the global _sym._tcp mesh
133
+ // (escape hatch: revert from a named group, with --force)
134
+ // '<kebab-name>' → user explicitly wants this named group
135
+ const explicitGroup = groupArg !== null ? groupArg
136
+ : (process.env.SYM_GROUP || null);
137
+
138
+ // resolveGroup: per-scope group resolution that respects both the user's
139
+ // explicit intent AND the existing entry's persisted state.
140
+ //
141
+ // With --force AND an explicit value: flag/env wins. The user is
142
+ // deliberately overriding state. `--force --group new-team` switches
143
+ // groups; `--force --group default` reverts to the global mesh.
144
+ //
145
+ // Without --force, OR with --force but no explicit value: preserve
146
+ // from the existing entry (heal-path job is to NOT lose user state).
147
+ // Falls back to the explicit value, then to none.
148
+ //
149
+ // Returns the SYM_GROUP value to write, or null to omit the key entirely
150
+ // (which the caller maps to "leave SYM_GROUP out of the env block, node
151
+ // uses default _sym._tcp on launch").
152
+ function resolveGroup(existingEntry) {
153
+ const preserved = preserveGroup(existingEntry);
154
+ if (force && explicitGroup !== null) {
155
+ return explicitGroup === 'default' ? null : explicitGroup;
156
+ }
157
+ if (preserved) return preserved;
158
+ if (explicitGroup && explicitGroup !== 'default') return explicitGroup;
159
+ return null;
160
+ }
161
+
92
162
  // ── Resolve server.js path ────────────────────────────────────────
93
163
 
94
164
  // Resolve server.js from the installed package location. require.resolve
@@ -153,6 +223,11 @@ if (useProjectMode) {
153
223
  // back to the hostname default on every reinstall.
154
224
  const projectNodeName = preserveNodeName(existingProjectEntry) || nodeName;
155
225
 
226
+ // Group resolution priority — see resolveGroup() at top of file.
227
+ // Summary: --force + explicit flag/env wins; otherwise preserve, then
228
+ // explicit, then omit. `--group default` with --force = revert to mesh.
229
+ const projectGroup = resolveGroup(existingProjectEntry);
230
+
156
231
  // Build the MCP entry (identical shape to global mode)
157
232
  const projectEntry = {
158
233
  command: 'node',
@@ -165,6 +240,11 @@ if (useProjectMode) {
165
240
  SYM_RELAY_TOKEN: '',
166
241
  },
167
242
  };
243
+ // SYM_GROUP is only written when explicitly set. Omitting it (rather than
244
+ // writing an empty string) keeps the JSON file minimal for the common
245
+ // single-team case AND avoids the "default group accidentally pinned"
246
+ // failure mode where a blank value masks the server.js fallback.
247
+ if (projectGroup) projectEntry.env.SYM_GROUP = projectGroup;
168
248
 
169
249
  // Backup existing .mcp.json if present
170
250
  let mcpBackupPath = null;
@@ -236,6 +316,7 @@ if (useProjectMode) {
236
316
  `✓ sym-mesh-channel configured for project: ${projectDir}`,
237
317
  '',
238
318
  ` Node name: ${projectNodeName}${projectEntryIsStale ? ' (preserved from stale entry)' : ''}`,
319
+ ` Mesh group: ${projectGroup || 'default (global _sym._tcp mesh)'}`,
239
320
  ` Server path: ${serverJsPath}`,
240
321
  ` Wrote: ${mcpJsonPath}`,
241
322
  ];
@@ -312,6 +393,7 @@ if (cmd === 'doctor') {
312
393
  scope: 'user-global',
313
394
  path: (topEntry.args || [])[0] || '(no path)',
314
395
  node: preserveNodeName(topEntry) || '(no SYM_NODE_NAME)',
396
+ group: preserveGroup(topEntry) || 'default',
315
397
  live: !isStaleEntry(topEntry),
316
398
  });
317
399
  }
@@ -323,6 +405,7 @@ if (cmd === 'doctor') {
323
405
  scope: `project ${projPath}`,
324
406
  path: (e.args || [])[0] || '(no path)',
325
407
  node: preserveNodeName(e) || '(no SYM_NODE_NAME)',
408
+ group: preserveGroup(e) || 'default',
326
409
  live: !isStaleEntry(e),
327
410
  });
328
411
  }
@@ -336,16 +419,34 @@ if (cmd === 'doctor') {
336
419
  console.log('');
337
420
  for (const r of rows) {
338
421
  console.log(` [${r.live ? 'live ' : 'STALE'}] ${r.scope}`);
339
- console.log(` node: ${r.node}`);
340
- console.log(` path: ${r.path}`);
422
+ console.log(` node: ${r.node}`);
423
+ console.log(` group: ${r.group}`);
424
+ console.log(` path: ${r.path}`);
341
425
  }
342
426
  const staleCount = rows.filter((r) => !r.live).length;
427
+
428
+ // Heuristic: if multiple entries reference the same Claude identity
429
+ // (same machine) but disagree on group, peers will see each other as
430
+ // disconnected — same incident pattern that cost ~24h of duplex outage
431
+ // at SYM.BOT (CMO=default vs COO=sym-bot-team, 2026-05-02). Surface as
432
+ // a warning so users can spot the mismatch before reaching for the
433
+ // troubleshooting section.
434
+ const groups = new Set(rows.map((r) => r.group));
435
+ const groupMismatch = rows.length > 1 && groups.size > 1;
436
+
343
437
  console.log('');
344
438
  if (staleCount > 0) {
345
439
  console.log(`${staleCount} stale entr${staleCount === 1 ? 'y' : 'ies'} — run \`sym-mesh-channel init\` to heal.`);
346
440
  } else {
347
441
  console.log('All entries are live.');
348
442
  }
443
+ if (groupMismatch) {
444
+ console.log('');
445
+ console.log(`⚠ Group mismatch across entries: ${Array.from(groups).join(', ')}.`);
446
+ console.log(' Nodes in different groups cannot discover each other on Bonjour.');
447
+ console.log(' If teammates expect to see each other, align the SYM_GROUP env var.');
448
+ console.log(' See README "Team mesh groups → Persisting your group across restarts".');
449
+ }
349
450
  process.exit(0);
350
451
  }
351
452
 
@@ -370,6 +471,11 @@ if (existingTopEntry && !force && !topEntryIsStale) {
370
471
  // Preserve the prior node name on rewrite so mesh identity doesn't drift.
371
472
  const topNodeName = preserveNodeName(existingTopEntry) || nodeName;
372
473
 
474
+ // Resolve SYM_GROUP for the global entry — see resolveGroup() at top.
475
+ // Heal-path default preserves; --force lets the user explicitly switch
476
+ // groups (or back to default mesh) in one command.
477
+ const topGroup = resolveGroup(existingTopEntry);
478
+
373
479
  // ── Build the entry ───────────────────────────────────────────────
374
480
 
375
481
  const entry = {
@@ -389,6 +495,9 @@ const entry = {
389
495
  SYM_RELAY_TOKEN: '',
390
496
  },
391
497
  };
498
+ // SYM_GROUP only emitted when explicitly chosen — see project-mode comment
499
+ // for the rationale. Omitted = node uses the global _sym._tcp default.
500
+ if (topGroup) entry.env.SYM_GROUP = topGroup;
392
501
 
393
502
  claudeJson.mcpServers['claude-sym-mesh'] = entry;
394
503
 
@@ -407,7 +516,11 @@ for (const [projPath, proj] of Object.entries(projects)) {
407
516
  if (!projEntry) continue;
408
517
  if (!isStaleEntry(projEntry)) continue;
409
518
  const projNodeName = preserveNodeName(projEntry) || nodeName;
410
- proj.mcpServers['claude-sym-mesh'] = {
519
+ // Preserve SYM_GROUP on stale-heal same reason as preserveNodeName.
520
+ // The user explicitly chose this group at some prior install; healing a
521
+ // path issue must not silently revert their group membership.
522
+ const projGroupName = preserveGroup(projEntry);
523
+ const healedEntry = {
411
524
  command: 'node',
412
525
  args: [serverJsPath],
413
526
  env: {
@@ -416,7 +529,9 @@ for (const [projPath, proj] of Object.entries(projects)) {
416
529
  SYM_RELAY_TOKEN: projEntry.env && typeof projEntry.env.SYM_RELAY_TOKEN === 'string' ? projEntry.env.SYM_RELAY_TOKEN : '',
417
530
  },
418
531
  };
419
- healedProjects.push({ path: projPath, node: projNodeName });
532
+ if (projGroupName) healedEntry.env.SYM_GROUP = projGroupName;
533
+ proj.mcpServers['claude-sym-mesh'] = healedEntry;
534
+ healedProjects.push({ path: projPath, node: projNodeName, group: projGroupName });
420
535
  }
421
536
 
422
537
  // ── Atomic write ──────────────────────────────────────────────────
@@ -459,7 +574,7 @@ const launchCmd = `claude --dangerously-load-development-channels server:claude-
459
574
 
460
575
  const healedLines = healedProjects.length
461
576
  ? '\n Healed stale project-scoped entries (now pointing at fresh server.js):\n' +
462
- healedProjects.map((p) => ` • ${p.path} (node: ${p.node})`).join('\n') + '\n'
577
+ healedProjects.map((p) => ` • ${p.path} (node: ${p.node}${p.group ? `, group: ${p.group}` : ''})`).join('\n') + '\n'
463
578
  : '';
464
579
 
465
580
  const nodeNameSuffix = topEntryIsStale ? ' (preserved from stale entry)' : '';
@@ -468,6 +583,7 @@ console.log(`
468
583
  ✓ sym-mesh-channel configured globally in ~/.claude.json
469
584
 
470
585
  Node name: ${topNodeName}${nodeNameSuffix}
586
+ Mesh group: ${topGroup || 'default (global _sym._tcp mesh)'}
471
587
  Server path: ${serverJsPath}
472
588
  Backup: ${backupPath}
473
589
  ${healedLines}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sym-bot/mesh-channel",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "MCP server — real-time agent-to-agent cognition for Claude Code remote teams via the SYM mesh.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.12.1",
25
- "@sym-bot/sym": "^0.5.1"
25
+ "@sym-bot/sym": "^0.5.8"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18"
package/server.js CHANGED
@@ -241,14 +241,25 @@ function registerNodeHandlers(n) {
241
241
  const focus = entry.cmb?.fields?.focus?.text || entry.content || '';
242
242
  const mood = entry.cmb?.fields?.mood?.text || '';
243
243
  const moodSuffix = mood && mood !== 'neutral' ? ` (mood: ${mood})` : '';
244
- // Store the rendered CMB body so the agent can sym_fetch it by [mNNN] ID,
245
- // matching the contract stated in the MCP instructions and the behaviour
246
- // of the raw-text `message` path. Without this, CAT7 CMBs (the primary
247
- // traffic sym_send / sym_observe) arrive as headlines with no
248
- // retrievable body, degrading real-time duplex to headline-only.
249
- const body = entry.content || focus;
244
+ // Store the rendered CMB body so the agent can sym_fetch it by [mNNN] ID.
245
+ // When the CMB carries an opaque payload alongside CAT7 fields, append a
246
+ // PAYLOAD section to the stored body so sym_fetch returns it intact;
247
+ // header gains a [+payload Nb] indicator so the receiver knows there's
248
+ // structured data beyond CAT7 and should sym_fetch to consume it.
249
+ const payload = entry.cmb?.payload;
250
+ const hasPayload = payload !== undefined && payload !== null;
251
+ let body = entry.content || focus;
252
+ let payloadSuffix = '';
253
+ if (hasPayload) {
254
+ const serialized = (() => {
255
+ try { return JSON.stringify(payload, null, 2); }
256
+ catch { return String(payload); }
257
+ })();
258
+ body = `${body}\n\n---PAYLOAD---\n${serialized}`;
259
+ payloadSuffix = ` [+payload ${serialized.length}b]`;
260
+ }
250
261
  const msgId = storeMessage(source, body);
251
- pushChannel('cmb', `[${source}] ${focus}${moodSuffix} [${msgId}]`);
262
+ pushChannel('cmb', `[${source}] ${focus}${moodSuffix}${payloadSuffix} [${msgId}]`);
252
263
  });
253
264
 
254
265
  n.on('message', (from, content) => {
@@ -335,6 +346,14 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
335
346
  'Target peer: either the peer display name (e.g. "claude-research-win") or the full nodeId. ' +
336
347
  'Call sym_peers first if unsure which peers are connected. Omit to broadcast to all peers.',
337
348
  },
349
+ payload: {
350
+ description:
351
+ 'Optional opaque payload riding alongside CAT7 fields. Use when carrying data beyond ' +
352
+ 'CAT7 — e.g. an LLM request/response substrate protocol puts the prompt + request_id ' +
353
+ 'in `payload` rather than smuggling JSON through `motivation` (which is reserved for ' +
354
+ 'CAT7 semantics). Receivers see the payload via sym_fetch on the channel notification. ' +
355
+ 'Any JSON-serializable value.',
356
+ },
338
357
  },
339
358
  required: ['focus'],
340
359
  },
@@ -362,6 +381,12 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
362
381
  arousal: { type: 'number' },
363
382
  },
364
383
  },
384
+ payload: {
385
+ description:
386
+ 'Optional opaque payload riding alongside CAT7 fields. Use when broadcasting data ' +
387
+ 'beyond CAT7 (e.g. llm-capability-advertise carrying served_capabilities). ' +
388
+ 'Any JSON-serializable value.',
389
+ },
365
390
  },
366
391
  required: ['focus'],
367
392
  },
@@ -493,7 +518,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
493
518
  targetPeerId = matches[0].peerId;
494
519
  }
495
520
 
496
- const entry = node.remember(fields, targetPeerId ? { to: targetPeerId } : {});
521
+ const sendOpts = {};
522
+ if (targetPeerId) sendOpts.to = targetPeerId;
523
+ if (args.payload !== undefined && args.payload !== null) sendOpts.payload = args.payload;
524
+ const entry = node.remember(fields, sendOpts);
497
525
  if (!entry) {
498
526
  return { content: [{ type: 'text', text: 'Duplicate — CMB already in memory, not re-broadcast.' }] };
499
527
  }
@@ -513,7 +541,9 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
513
541
  perspective: args.perspective || NODE_NAME,
514
542
  mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
515
543
  };
516
- const entry = node.remember(fields);
544
+ const observeOpts = {};
545
+ if (args.payload !== undefined && args.payload !== null) observeOpts.payload = args.payload;
546
+ const entry = node.remember(fields, observeOpts);
517
547
  return { content: [{ type: 'text', text: entry ? `Observed: ${entry.key}` : 'Duplicate — already in memory.' }] };
518
548
  }
519
549