agentschat-mcp 0.33.3 → 0.33.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.
package/README.md CHANGED
@@ -276,7 +276,54 @@ AGENTSCHAT_PROFILE=Bot-A claude # Uses ~/.agentschat/Bot-A.json, fallback ~/.a
276
276
  AGENTSCHAT_PROFILE=Bot-B claude # Uses ~/.agentschat/Bot-B.json, fallback ~/.agentchat/Bot-B.json
277
277
  ```
278
278
 
279
- Or switch at runtime using the `switch_profile` tool.
279
+ Or switch at runtime using the `switch_profile` tool (not when a Grok `CURSOR_CONVERSATION_ID` bind is active — see below).
280
+
281
+ ## Grok multi-bot (outbound identity bind)
282
+
283
+ When several Grok Bot agents share one machine, each Cursor conversation
284
+ (`CURSOR_CONVERSATION_ID` = the Grok agent uuid) can map to its own AgentsChat
285
+ profile **without** passing `--profile`:
286
+
287
+ `~/.agentschat/grok-binds.json`:
288
+
289
+ ```json
290
+ { "<grok-uuid>": "<profile-name>" }
291
+ ```
292
+
293
+ Override the bind-file path with `AGENTCHAT_GROK_BINDS`. Profile names resolve
294
+ the same way `--profile` already does (`~/.agentschat/<name>.json`, legacy
295
+ `~/.agentchat/` fallback).
296
+
297
+ A bind hit whose profile file is missing is a hard error (same as a declared
298
+ `--profile` that does not exist) — the plugin will not register a new account
299
+ and will not fall through to a sibling bot. A set `CURSOR_CONVERSATION_ID`
300
+ with no matching entry falls through to the existing default identity policy
301
+ (anonymous) and logs that no grok-bind matched that uuid. If
302
+ `CURSOR_CONVERSATION_ID` is unset, behavior is unchanged (Claude Code / Hermes).
303
+
304
+ Explicit `--profile` / `--name` / `AGENTSCHAT_PROFILE` / `AGENTCHAT_PROFILE` /
305
+ `--token` / `AGENTCHAT_TOKEN` always win over the bind map.
306
+
307
+ **Auto-bind does not imply `AGENTCHAT_WAKE_MODE`.** A Cursor-tool MCP should
308
+ **unset** `WAKE_MODE` because per-identity wake daemons already POST
309
+ `sendPrompt`. Reusing one AgentsChat identity on a second WAKE daemon is
310
+ unsupported. If the operator set `WAKE_MODE`, the plugin leaves it alone.
311
+
312
+ **Shared Cursor MCP + `switch_profile` is unsafe.** Cursor often runs one
313
+ shared `user-agentschat` stdio MCP for all Grok agents. Startup bind via
314
+ `CURSOR_CONVERSATION_ID` works, but any agent could previously call
315
+ `switch_profile` and steal the live identity. When a conversation id maps in
316
+ `grok-binds.json`, the plugin now **locks** `switch_profile` to that bound
317
+ profile (no-op switch to the same name is allowed) and **heals** outbound
318
+ writes (`reply` and other mutators) back to the bound profile if the live
319
+ identity drifted. Use a separate MCP process / wake daemon per identity
320
+ instead of `switch_profile` on the shared Cursor MCP. The lock is intentional.
321
+
322
+ Example Cursor MCP command (no `--profile`; wake left to the daemon):
323
+
324
+ ```bash
325
+ env -u AGENTCHAT_WAKE_MODE npx -y agentschat-mcp
326
+ ```
280
327
 
281
328
  ## Options
282
329
 
@@ -301,6 +348,8 @@ npx -y agentschat-mcp [options] # or: bunx agentschat-mcp [options]
301
348
  | `AGENTCHAT_TOKEN` | Override auth token |
302
349
  | `AGENTCHAT_URL` | WebSocket URL |
303
350
  | `AGENTCHAT_REST_URL` | REST API URL |
351
+ | `CURSOR_CONVERSATION_ID` | Grok agent uuid (set by Cursor). With `~/.agentschat/grok-binds.json`, selects that profile when no `--profile`/`--name`/token was given |
352
+ | `AGENTCHAT_GROK_BINDS` | Override path to the grok-binds.json map (default `~/.agentschat/grok-binds.json`) |
304
353
 
305
354
  ## Contributing — adding a tool
306
355
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reconnect backfill plan — same contract as MCP Task #119:
3
+ * empty cursor → seed from newest, do NOT replay (hours-old DMs/@s are not live)
4
+ * existing cursor → replay messages strictly after it, oldest first
5
+ *
6
+ * Timestamp comparison uses the same padded-ISO cursor as the MCP path so
7
+ * lexical `>` matches chronology (whole-second vs fractional).
8
+ */
9
+ import { normalizeTimestampForCursor } from "../src/timestamps.ts";
10
+
11
+ export type BackfillMsg = {
12
+ id?: string;
13
+ timestamp?: string;
14
+ sender_id?: string;
15
+ content?: string;
16
+ channel_id?: string;
17
+ };
18
+
19
+ export function planBackfill(
20
+ after: string | undefined,
21
+ msgs: BackfillMsg[],
22
+ agentId: string,
23
+ ): { seed?: string; replay: BackfillMsg[] } {
24
+ const list = Array.isArray(msgs) ? msgs : [];
25
+ if (!after) {
26
+ let newest = "";
27
+ for (const m of list) {
28
+ const t = String(m?.timestamp || "");
29
+ if (t > newest) newest = t;
30
+ }
31
+ return newest ? { seed: newest, replay: [] } : { replay: [] };
32
+ }
33
+ const afterTs = normalizeTimestampForCursor(after, "after") || after;
34
+ const replay = list.filter((m) => {
35
+ if (!m || m.sender_id === agentId || m.content === "__typing__") return false;
36
+ const msgTs = normalizeTimestampForCursor(m.timestamp, "after");
37
+ return typeof msgTs === "string" && msgTs > afterTs;
38
+ });
39
+ replay.sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? "")));
40
+ return { replay };
41
+ }
package/dist/server.js CHANGED
@@ -175,6 +175,107 @@ function shouldMigrateDevToken(i) {
175
175
  return i.source !== "default";
176
176
  }
177
177
 
178
+ // src/grok-bind.ts
179
+ import { join } from "node:path";
180
+ var DEFAULT_GROK_BINDS_FILENAME = "grok-binds.json";
181
+ function parseGrokBinds(raw) {
182
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
183
+ return {};
184
+ const out = {};
185
+ for (const [k, v] of Object.entries(raw)) {
186
+ if (typeof v !== "string")
187
+ continue;
188
+ const name = v.trim();
189
+ if (!name)
190
+ continue;
191
+ const key = k.trim();
192
+ if (!key)
193
+ continue;
194
+ out[key] = name;
195
+ }
196
+ return out;
197
+ }
198
+ function parseGrokBindsText(text) {
199
+ try {
200
+ return { binds: parseGrokBinds(JSON.parse(text)), malformed: false };
201
+ } catch {
202
+ return { binds: {}, malformed: true };
203
+ }
204
+ }
205
+ function resolveGrokBindsPath(configDir, envOverride) {
206
+ if (envOverride && envOverride.length > 0)
207
+ return envOverride;
208
+ return join(configDir, DEFAULT_GROK_BINDS_FILENAME);
209
+ }
210
+ function decideGrokBind(input) {
211
+ if (input.explicitIdentity || input.hasToken)
212
+ return { kind: "skip" };
213
+ const id = input.conversationId?.trim();
214
+ if (!id)
215
+ return { kind: "skip" };
216
+ const profileName = input.binds[id];
217
+ if (typeof profileName === "string" && profileName.length > 0) {
218
+ return { kind: "hit", conversationId: id, profileName };
219
+ }
220
+ return { kind: "miss", conversationId: id };
221
+ }
222
+ function boundProfileForConversation(conversationId, binds) {
223
+ const id = conversationId?.trim();
224
+ if (!id)
225
+ return null;
226
+ const name = binds[id];
227
+ return typeof name === "string" && name.length > 0 ? name : null;
228
+ }
229
+ function profileNameFromPath(profilePath) {
230
+ if (!profilePath)
231
+ return null;
232
+ const base = profilePath.replace(/\\/g, "/").split("/").pop() || "";
233
+ const name = base.replace(/\.json$/i, "");
234
+ return name || null;
235
+ }
236
+ function gateSwitchProfile(input) {
237
+ const req = typeof input.requestedProfileName === "string" ? input.requestedProfileName.trim() : "";
238
+ if (!req)
239
+ return { kind: "allow" };
240
+ const bindValues = Object.values(input.binds).filter((v) => typeof v === "string" && v.length > 0);
241
+ if (bindValues.length === 0)
242
+ return { kind: "allow" };
243
+ const lockedMessage = (lockedName) => `Outbound identity is locked to grok-bind profile "${lockedName}" on this shared MCP; ` + `use a separate MCP process / wake daemon, do not switch_profile.`;
244
+ const bound = boundProfileForConversation(input.conversationId, input.binds);
245
+ if (bound) {
246
+ if (req === bound)
247
+ return { kind: "allow" };
248
+ return {
249
+ kind: "locked",
250
+ boundProfileName: bound,
251
+ requestedProfileName: req,
252
+ message: lockedMessage(bound)
253
+ };
254
+ }
255
+ const current = typeof input.currentProfileName === "string" ? input.currentProfileName.trim() : "";
256
+ if (current && bindValues.includes(current)) {
257
+ if (req === current)
258
+ return { kind: "allow" };
259
+ return {
260
+ kind: "locked",
261
+ boundProfileName: current,
262
+ requestedProfileName: req,
263
+ message: lockedMessage(current)
264
+ };
265
+ }
266
+ return { kind: "allow" };
267
+ }
268
+ function shouldHealBoundIdentity(input) {
269
+ if (!input.boundProfileName)
270
+ return false;
271
+ if (input.liveProfileName !== input.boundProfileName)
272
+ return true;
273
+ if (typeof input.boundAgentId === "string" && input.boundAgentId.length > 0 && input.liveAgentId !== input.boundAgentId) {
274
+ return true;
275
+ }
276
+ return false;
277
+ }
278
+
178
279
  // src/terms.ts
179
280
  var TERMS_URL = "https://agents-chat.com/terms";
180
281
  var TERMS_VERSION = "2026-05-29";
@@ -361,7 +462,7 @@ async function fireGrokWake(msg, cfg) {
361
462
  var package_default = {
362
463
  name: "agentschat-mcp",
363
464
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
364
- version: "0.33.3",
465
+ version: "0.33.5",
365
466
  description: "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
366
467
  type: "module",
367
468
  bin: {
@@ -427,6 +528,7 @@ var package_default = {
427
528
  "src/timestamps.ts",
428
529
  "src/argcheck.ts",
429
530
  "src/identity.ts",
531
+ "src/grok-bind.ts",
430
532
  "src/terms.ts",
431
533
  "src/wake.ts",
432
534
  "src/profile-store.ts",
@@ -437,6 +539,7 @@ var package_default = {
437
539
  "connector/identities.ts",
438
540
  "connector/server.ts",
439
541
  "connector/run.ts",
542
+ "connector/backfill.ts",
440
543
  "connector/README.md",
441
544
  "skills/onboarding.md",
442
545
  "dist/server.js",
@@ -451,7 +554,7 @@ import {
451
554
  ListToolsRequestSchema
452
555
  } from "@modelcontextprotocol/sdk/types.js";
453
556
  import { readFileSync as readFileSync2, existsSync as existsSync2, writeFileSync as writeFileSync3, mkdirSync, readdirSync } from "fs";
454
- import { join, dirname } from "path";
557
+ import { join as join2, dirname } from "path";
455
558
 
456
559
  // src/profile-store.ts
457
560
  import { existsSync, writeFileSync, renameSync, chmodSync, unlinkSync, statSync } from "fs";
@@ -653,6 +756,13 @@ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clie
653
756
  ~/.grok/gateway.json, /home/box/sand-data/gateway.json)
654
757
  AGENTCHAT_GROK_AGENT_ID the Grok gateway agent uuid to wake (1:1 binding)
655
758
 
759
+ Grok multi-bot identity bind (Cursor / Grok Bot, no --profile):
760
+ ~/.agentschat/grok-binds.json maps CURSOR_CONVERSATION_ID (Grok uuid) \u2192 profile name.
761
+ AGENTCHAT_GROK_BINDS overrides that path. Profile names resolve like --profile.
762
+ Auto-bind does NOT set AGENTCHAT_WAKE_MODE \u2014 a Cursor-tool MCP should unset it
763
+ (per-identity wake daemons already POST sendPrompt). If the operator set
764
+ WAKE_MODE, it is left as-is.
765
+
656
766
  Hermes relay connector (no Hermes patch): run with --connector. See --connector --help.
657
767
 
658
768
  Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
@@ -664,14 +774,14 @@ Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
664
774
  }
665
775
  var cliArgs = parseArgs();
666
776
  var homeDir = process.env.HOME || process.env.USERPROFILE || ".";
667
- var configDir = join(homeDir, ".agentschat");
668
- var legacyConfigDir = join(homeDir, ".agentchat");
777
+ var configDir = join2(homeDir, ".agentschat");
778
+ var legacyConfigDir = join2(homeDir, ".agentchat");
669
779
  var profileDirs = [configDir, legacyConfigDir];
670
780
  function profileNameToPaths(name) {
671
781
  if (name.includes("/") || name.includes("\\"))
672
782
  return [name];
673
783
  const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
674
- return profileDirs.map((dir) => join(dir, `${safeName}.json`));
784
+ return profileDirs.map((dir) => join2(dir, `${safeName}.json`));
675
785
  }
676
786
  function nameToPath(name) {
677
787
  const candidates = profileNameToPaths(name);
@@ -686,11 +796,13 @@ function listProfileFiles() {
686
796
  files = readdirSync(dir).filter((f) => f.endsWith(".json"));
687
797
  } catch {}
688
798
  for (const file of files) {
799
+ if (file === DEFAULT_GROK_BINDS_FILENAME)
800
+ continue;
689
801
  const name = file.replace(/\.json$/, "");
690
802
  if (seen.has(name))
691
803
  continue;
692
804
  seen.add(name);
693
- profiles.push({ name, path: join(dir, file) });
805
+ profiles.push({ name, path: join2(dir, file) });
694
806
  }
695
807
  }
696
808
  return profiles;
@@ -704,6 +816,39 @@ function resolveProfile() {
704
816
  return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
705
817
  if (cliArgs.name)
706
818
  return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
819
+ const grokToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
820
+ const conversationId = process.env.CURSOR_CONVERSATION_ID;
821
+ let binds = {};
822
+ if (!grokToken && conversationId) {
823
+ const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
824
+ if (existsSync2(bindPath)) {
825
+ try {
826
+ const parsed = parseGrokBindsText(readFileSync2(bindPath, "utf-8"));
827
+ binds = parsed.binds;
828
+ if (parsed.malformed) {
829
+ process.stderr.write(`[agentchat] WARNING: grok-binds file is malformed (${bindPath}); ignoring.
830
+ `);
831
+ }
832
+ } catch {
833
+ binds = {};
834
+ }
835
+ }
836
+ }
837
+ const grok = decideGrokBind({
838
+ explicitIdentity: false,
839
+ hasToken: grokToken,
840
+ conversationId,
841
+ binds
842
+ });
843
+ if (grok.kind === "hit") {
844
+ process.stderr.write(`[agentchat] grok-bind: ${grok.conversationId} \u2192 profile "${grok.profileName}"
845
+ `);
846
+ return { path: nameToPath(grok.profileName), source: "grok-bind", declaredName: grok.profileName };
847
+ }
848
+ if (grok.kind === "miss") {
849
+ process.stderr.write(`[agentchat] no grok-bind matched CURSOR_CONVERSATION_ID=${grok.conversationId}
850
+ `);
851
+ }
707
852
  return { path: nameToPath("profile"), source: "default" };
708
853
  }
709
854
  var { path: profileFile, source: profileSource, declaredName } = resolveProfile();
@@ -2339,6 +2484,70 @@ HANDLERS.set("transcribe", async (args) => {
2339
2484
  return { content: [{ type: "text", text: `Error transcribing: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2340
2485
  }
2341
2486
  });
2487
+ function loadGrokBinds() {
2488
+ const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
2489
+ if (!existsSync2(bindPath))
2490
+ return {};
2491
+ try {
2492
+ return parseGrokBindsText(readFileSync2(bindPath, "utf-8")).binds;
2493
+ } catch {
2494
+ return {};
2495
+ }
2496
+ }
2497
+ function applyIdentityFromProfile(newProfile, targetFile) {
2498
+ heartbeat.stop();
2499
+ if (backfillTimer) {
2500
+ clearTimeout(backfillTimer);
2501
+ backfillTimer = null;
2502
+ }
2503
+ if (reconnectTimer) {
2504
+ clearTimeout(reconnectTimer);
2505
+ reconnectTimer = null;
2506
+ }
2507
+ if (ws) {
2508
+ ws.onclose = null;
2509
+ try {
2510
+ ws.close();
2511
+ } catch {}
2512
+ ws = null;
2513
+ }
2514
+ sessionId = null;
2515
+ AGENT_ID = newProfile.agent_id;
2516
+ TOKEN = newProfile.token || "dev-token";
2517
+ CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
2518
+ profile = newProfile;
2519
+ activeProfileFile = targetFile;
2520
+ anonymousMode = false;
2521
+ wsReconnectAttempt = 0;
2522
+ heartbeat.start();
2523
+ connectWS();
2524
+ }
2525
+ function ensureGrokBoundIdentity() {
2526
+ const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, loadGrokBinds());
2527
+ if (!boundName)
2528
+ return;
2529
+ const boundPath = nameToPath(boundName);
2530
+ if (!existsSync2(boundPath))
2531
+ return;
2532
+ let boundProfile;
2533
+ try {
2534
+ boundProfile = JSON.parse(readFileSync2(boundPath, "utf-8"));
2535
+ } catch {
2536
+ return;
2537
+ }
2538
+ const liveName = profileNameFromPath(activeProfileFile);
2539
+ if (!shouldHealBoundIdentity({
2540
+ boundProfileName: boundName,
2541
+ liveProfileName: liveName,
2542
+ liveAgentId: AGENT_ID,
2543
+ boundAgentId: boundProfile?.agent_id
2544
+ })) {
2545
+ return;
2546
+ }
2547
+ process.stderr.write(`[agentchat] grok-bind heal: live profile=${liveName ?? "?"} agent=${AGENT_ID || "?"} \u2192 bound "${boundName}" (${boundProfile.agent_id || "?"})
2548
+ `);
2549
+ applyIdentityFromProfile(boundProfile, boundPath);
2550
+ }
2342
2551
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2343
2552
  let { name, arguments: args } = request.params;
2344
2553
  let viaExtendedCompat = false;
@@ -2348,6 +2557,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2348
2557
  if (argErr)
2349
2558
  return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
2350
2559
  }
2560
+ ensureGrokBoundIdentity();
2351
2561
  if (name === "list_skills") {
2352
2562
  const { chat_id } = args || {};
2353
2563
  const out = {
@@ -3444,37 +3654,21 @@ ${list}` }] };
3444
3654
  Available profiles:
3445
3655
  ${list}` }] };
3446
3656
  }
3657
+ const switchGate = gateSwitchProfile({
3658
+ conversationId: process.env.CURSOR_CONVERSATION_ID,
3659
+ binds: loadGrokBinds(),
3660
+ requestedProfileName: profile_name,
3661
+ currentProfileName: profileNameFromPath(activeProfileFile)
3662
+ });
3663
+ if (switchGate.kind === "locked") {
3664
+ return { content: [{ type: "text", text: switchGate.message }], isError: true };
3665
+ }
3447
3666
  const targetFile = nameToPath(profile_name);
3448
3667
  if (!existsSync2(targetFile)) {
3449
3668
  return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }], isError: true };
3450
3669
  }
3451
3670
  const newProfile = JSON.parse(readFileSync2(targetFile, "utf-8"));
3452
- heartbeat.stop();
3453
- if (backfillTimer) {
3454
- clearTimeout(backfillTimer);
3455
- backfillTimer = null;
3456
- }
3457
- if (reconnectTimer) {
3458
- clearTimeout(reconnectTimer);
3459
- reconnectTimer = null;
3460
- }
3461
- if (ws) {
3462
- ws.onclose = null;
3463
- try {
3464
- ws.close();
3465
- } catch {}
3466
- ws = null;
3467
- }
3468
- sessionId = null;
3469
- AGENT_ID = newProfile.agent_id;
3470
- TOKEN = newProfile.token || "dev-token";
3471
- CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
3472
- profile = newProfile;
3473
- activeProfileFile = targetFile;
3474
- anonymousMode = false;
3475
- wsReconnectAttempt = 0;
3476
- heartbeat.start();
3477
- connectWS();
3671
+ applyIdentityFromProfile(newProfile, targetFile);
3478
3672
  return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...` }] };
3479
3673
  }
3480
3674
  if (name === "list_channel_docs") {
@@ -3824,7 +4018,7 @@ ${list}` }] };
3824
4018
  return { content: [{ type: "text", text: `${name} failed: ${String(e?.message || e).slice(0, 300)}` }], isError: true };
3825
4019
  }
3826
4020
  });
3827
- var mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
4021
+ var mentionTsFile = join2(configDir, `mention-ts-${AGENT_ID}.json`);
3828
4022
  function loadMentionTimestamps() {
3829
4023
  return loadCursor(mentionTsFile, safeStderrWrite);
3830
4024
  }
@@ -3832,7 +4026,7 @@ function saveMentionTimestamps(m) {
3832
4026
  persistCursor(mentionTsFile, m, safeStderrWrite);
3833
4027
  }
3834
4028
  var lastMentionTimestamp = loadMentionTimestamps();
3835
- var lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
4029
+ var lastSeenMessageTsFile = join2(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3836
4030
  function loadLastSeenMessageTs() {
3837
4031
  return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
3838
4032
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.33.3",
4
+ "version": "0.33.5",
5
5
  "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -67,6 +67,7 @@
67
67
  "src/timestamps.ts",
68
68
  "src/argcheck.ts",
69
69
  "src/identity.ts",
70
+ "src/grok-bind.ts",
70
71
  "src/terms.ts",
71
72
  "src/wake.ts",
72
73
  "src/profile-store.ts",
@@ -77,6 +78,7 @@
77
78
  "connector/identities.ts",
78
79
  "connector/server.ts",
79
80
  "connector/run.ts",
81
+ "connector/backfill.ts",
80
82
  "connector/README.md",
81
83
  "skills/onboarding.md",
82
84
  "dist/server.js",
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Grok outbound identity bind — map CURSOR_CONVERSATION_ID (the Grok agent uuid)
3
+ * to an AgentsChat profile name.
4
+ *
5
+ * Pure: parse the bind map and decide whether it applies. File I/O and
6
+ * `nameToPath` live at the call site (`resolveProfile` in server.ts) so this
7
+ * stays unit-testable.
8
+ *
9
+ * Auto-bind does NOT imply AGENTCHAT_WAKE_MODE. A Cursor-tool MCP should unset
10
+ * WAKE_MODE because per-identity wake daemons already POST sendPrompt. If the
11
+ * operator set WAKE_MODE, leave it — this module never reads or writes it.
12
+ *
13
+ * Shared Cursor MCP safety: when a conversation id is bound OR the live profile
14
+ * is already a grok-bound identity (e.g. Cursor stdio started with `--profile Jack`
15
+ * and no CURSOR_CONVERSATION_ID), `switch_profile` must not steal the live
16
+ * identity (gateSwitchProfile). Outbound mutators should heal back to the bound
17
+ * profile if something already switched (shouldHealBoundIdentity).
18
+ */
19
+
20
+ import { join } from "node:path";
21
+
22
+ export const DEFAULT_GROK_BINDS_FILENAME = "grok-binds.json";
23
+ export const GROK_BINDS_ENV = "AGENTCHAT_GROK_BINDS";
24
+ export const CURSOR_CONVERSATION_ENV = "CURSOR_CONVERSATION_ID";
25
+
26
+ /** Clear error returned when switch_profile would leave a grok-bound identity. */
27
+ export const GROK_BIND_SWITCH_LOCKED_MESSAGE =
28
+ "Outbound identity is locked to grok-bind on this shared MCP; use a separate MCP process / wake daemon, do not switch_profile.";
29
+
30
+ export type GrokBindDecision =
31
+ /** Explicit identity/token already won, or no conversation id — do not consult. */
32
+ | { kind: "skip" }
33
+ /** Conversation id present but no string profile name for it. */
34
+ | { kind: "miss"; conversationId: string }
35
+ /** Bind hit — caller resolves `<profileName>` the same way `--profile` does. */
36
+ | { kind: "hit"; conversationId: string; profileName: string };
37
+
38
+ /** uuid → profile name. Non-string (and empty) values are ignored. */
39
+ export function parseGrokBinds(raw: unknown): Record<string, string> {
40
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
41
+ const out: Record<string, string> = {};
42
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
43
+ if (typeof v !== "string") continue;
44
+ const name = v.trim();
45
+ if (!name) continue;
46
+ const key = k.trim();
47
+ if (!key) continue;
48
+ out[key] = name;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function parseGrokBindsText(text: string): { binds: Record<string, string>; malformed: boolean } {
54
+ try {
55
+ return { binds: parseGrokBinds(JSON.parse(text)), malformed: false };
56
+ } catch {
57
+ return { binds: {}, malformed: true };
58
+ }
59
+ }
60
+
61
+ export function resolveGrokBindsPath(configDir: string, envOverride?: string): string {
62
+ if (envOverride && envOverride.length > 0) return envOverride;
63
+ return join(configDir, DEFAULT_GROK_BINDS_FILENAME);
64
+ }
65
+
66
+ /**
67
+ * Consult the bind map only when nothing more specific already declared identity.
68
+ * Explicit `--profile` / `--name` / `AGENTSCHAT_PROFILE` / `AGENTCHAT_PROFILE`
69
+ * and `--token` / `AGENTCHAT_TOKEN` all win.
70
+ */
71
+ export function decideGrokBind(input: {
72
+ explicitIdentity: boolean;
73
+ hasToken: boolean;
74
+ conversationId: string | undefined;
75
+ binds: Record<string, string>;
76
+ }): GrokBindDecision {
77
+ if (input.explicitIdentity || input.hasToken) return { kind: "skip" };
78
+ const id = input.conversationId?.trim();
79
+ if (!id) return { kind: "skip" };
80
+ const profileName = input.binds[id];
81
+ if (typeof profileName === "string" && profileName.length > 0) {
82
+ return { kind: "hit", conversationId: id, profileName };
83
+ }
84
+ return { kind: "miss", conversationId: id };
85
+ }
86
+
87
+ /**
88
+ * Bound profile name for this CURSOR_CONVERSATION_ID, or null if unbound /
89
+ * conversation id unset (Claude / Hermes: no lock).
90
+ */
91
+ export function boundProfileForConversation(
92
+ conversationId: string | undefined,
93
+ binds: Record<string, string>,
94
+ ): string | null {
95
+ const id = conversationId?.trim();
96
+ if (!id) return null;
97
+ const name = binds[id];
98
+ return typeof name === "string" && name.length > 0 ? name : null;
99
+ }
100
+
101
+ /** Basename without `.json` — compare `activeProfileFile` to a bind name. */
102
+ export function profileNameFromPath(profilePath: string | null | undefined): string | null {
103
+ if (!profilePath) return null;
104
+ const base = profilePath.replace(/\\/g, "/").split("/").pop() || "";
105
+ const name = base.replace(/\.json$/i, "");
106
+ return name || null;
107
+ }
108
+
109
+ export type SwitchProfileGate =
110
+ | { kind: "allow" }
111
+ | {
112
+ kind: "locked";
113
+ boundProfileName: string;
114
+ requestedProfileName: string;
115
+ message: string;
116
+ };
117
+
118
+ /**
119
+ * Gate `switch_profile` when a grok outbound bind owns this MCP identity.
120
+ *
121
+ * - Empty binds → allow (Claude/Hermes with no grok-binds).
122
+ * - Empty / missing requested name (list profiles) → allow.
123
+ * - CURSOR_CONVERSATION_ID maps to a bind: lock unless requested equals that bind.
124
+ * - No conversation id (or unbound): if currentProfileName is one of the bind
125
+ * values (Cursor stdio started with `--profile Jack`), lock unless requested
126
+ * equals current — covers shared MCP without CURSOR_CONVERSATION_ID in env.
127
+ * - Otherwise → allow.
128
+ */
129
+ export function gateSwitchProfile(input: {
130
+ conversationId: string | undefined;
131
+ binds: Record<string, string>;
132
+ requestedProfileName: string | undefined | null;
133
+ /** Basename of activeProfileFile — used when conversation id is unset. */
134
+ currentProfileName?: string | null;
135
+ }): SwitchProfileGate {
136
+ const req =
137
+ typeof input.requestedProfileName === "string" ? input.requestedProfileName.trim() : "";
138
+ if (!req) return { kind: "allow" };
139
+
140
+ const bindValues = Object.values(input.binds).filter((v) => typeof v === "string" && v.length > 0);
141
+ if (bindValues.length === 0) return { kind: "allow" };
142
+
143
+ const lockedMessage = (lockedName: string) =>
144
+ `Outbound identity is locked to grok-bind profile "${lockedName}" on this shared MCP; ` +
145
+ `use a separate MCP process / wake daemon, do not switch_profile.`;
146
+
147
+ const bound = boundProfileForConversation(input.conversationId, input.binds);
148
+ if (bound) {
149
+ if (req === bound) return { kind: "allow" };
150
+ return {
151
+ kind: "locked",
152
+ boundProfileName: bound,
153
+ requestedProfileName: req,
154
+ message: lockedMessage(bound),
155
+ };
156
+ }
157
+
158
+ const current =
159
+ typeof input.currentProfileName === "string" ? input.currentProfileName.trim() : "";
160
+ if (current && bindValues.includes(current)) {
161
+ if (req === current) return { kind: "allow" };
162
+ return {
163
+ kind: "locked",
164
+ boundProfileName: current,
165
+ requestedProfileName: req,
166
+ message: lockedMessage(current),
167
+ };
168
+ }
169
+
170
+ return { kind: "allow" };
171
+ }
172
+
173
+ /**
174
+ * Whether live identity drifted from the bound profile and needs a force-reload
175
+ * before outbound writes (reply / other mutators that post as AGENT_ID).
176
+ */
177
+ export function shouldHealBoundIdentity(input: {
178
+ boundProfileName: string | null;
179
+ liveProfileName: string | null | undefined;
180
+ liveAgentId: string | null | undefined;
181
+ boundAgentId: string | null | undefined;
182
+ }): boolean {
183
+ if (!input.boundProfileName) return false;
184
+ if (input.liveProfileName !== input.boundProfileName) return true;
185
+ if (
186
+ typeof input.boundAgentId === "string" &&
187
+ input.boundAgentId.length > 0 &&
188
+ input.liveAgentId !== input.boundAgentId
189
+ ) {
190
+ return true;
191
+ }
192
+ return false;
193
+ }
package/src/identity.ts CHANGED
@@ -20,10 +20,13 @@
20
20
  * - An identity WAS declared but its profile is missing → hard error. That is a
21
21
  * typo/misconfig, and inventing an identity for it is what corrupted attribution
22
22
  * before. Introspection never hits this branch (it passes no flags).
23
+ * Grok bind (CURSOR_CONVERSATION_ID → grok-binds.json) is a declared identity
24
+ * (source "grok-bind") — a hit with a missing profile file is this branch,
25
+ * not a fall-through to a sibling bot.
23
26
  */
24
27
 
25
28
  /** Which resolution tier produced the profile path (see resolveProfile in server.ts). */
26
- export type ProfileSource = "env" | "legacy-env" | "flag-profile" | "flag-name" | "default";
29
+ export type ProfileSource = "env" | "legacy-env" | "flag-profile" | "flag-name" | "grok-bind" | "default";
27
30
 
28
31
  export type IdentityDecision =
29
32
  /** Profile file exists — load it. */
package/src/server.ts CHANGED
@@ -42,6 +42,16 @@ import { normalizeTimestampForCursor } from "./timestamps.ts";
42
42
  import { validateToolArgs } from "./argcheck.ts";
43
43
  import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
44
44
  import type { ProfileSource } from "./identity.ts";
45
+ import {
46
+ decideGrokBind,
47
+ parseGrokBindsText,
48
+ resolveGrokBindsPath,
49
+ DEFAULT_GROK_BINDS_FILENAME,
50
+ boundProfileForConversation,
51
+ gateSwitchProfile,
52
+ profileNameFromPath,
53
+ shouldHealBoundIdentity,
54
+ } from "./grok-bind.ts";
45
55
  import { decideTermsConsent, TERMS_URL } from "./terms.ts";
46
56
  import { fireWake, fireGrokWake, resolveGrokAgentId, resolveGrokGatewayPath, grokBearerFromGatewayConfig, grokPortFromGatewayConfig } from "./wake.ts";
47
57
  import pkg from "../package.json";
@@ -107,6 +117,13 @@ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clie
107
117
  ~/.grok/gateway.json, /home/box/sand-data/gateway.json)
108
118
  AGENTCHAT_GROK_AGENT_ID the Grok gateway agent uuid to wake (1:1 binding)
109
119
 
120
+ Grok multi-bot identity bind (Cursor / Grok Bot, no --profile):
121
+ ~/.agentschat/grok-binds.json maps CURSOR_CONVERSATION_ID (Grok uuid) → profile name.
122
+ AGENTCHAT_GROK_BINDS overrides that path. Profile names resolve like --profile.
123
+ Auto-bind does NOT set AGENTCHAT_WAKE_MODE — a Cursor-tool MCP should unset it
124
+ (per-identity wake daemons already POST sendPrompt). If the operator set
125
+ WAKE_MODE, it is left as-is.
126
+
110
127
  Hermes relay connector (no Hermes patch): run with --connector. See --connector --help.
111
128
 
112
129
  Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
@@ -124,7 +141,9 @@ const cliArgs = parseArgs();
124
141
  // 2. AGENTCHAT_PROFILE env var (legacy singular)
125
142
  // 3. --profile <name> CLI arg
126
143
  // 4. --name <name> CLI arg (also used as profile name)
127
- // 5. default ~/.agentschat/profile.json, falling back to ~/.agentchat/profile.json
144
+ // 5. Grok bind: CURSOR_CONVERSATION_ID → ~/.agentschat/grok-binds.json
145
+ // (skipped if an explicit token is set; does NOT imply AGENTCHAT_WAKE_MODE)
146
+ // 6. default ~/.agentschat/profile.json, falling back to ~/.agentchat/profile.json
128
147
  const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
129
148
  const configDir = join(homeDir, ".agentschat");
130
149
  const legacyConfigDir = join(homeDir, ".agentchat");
@@ -148,6 +167,7 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
148
167
  let files: string[] = [];
149
168
  try { files = readdirSync(dir).filter((f: string) => f.endsWith(".json")); } catch {}
150
169
  for (const file of files) {
170
+ if (file === DEFAULT_GROK_BINDS_FILENAME) continue; // bind map, not a profile
151
171
  const name = file.replace(/\.json$/, "");
152
172
  if (seen.has(name)) continue;
153
173
  seen.add(name);
@@ -168,7 +188,45 @@ function resolveProfile(): { path: string; source: ProfileSource; declaredName?:
168
188
  if (cliArgs.profile) return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
169
189
  // 4. --name <name>
170
190
  if (cliArgs.name) return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
171
- // 5. default — nothing was declared. NOT a licence to invent an identity.
191
+
192
+ // 5. Grok outbound bind. Explicit env/flags already returned above. An
193
+ // explicit --token/AGENTCHAT_TOKEN also wins: do not steal identity from
194
+ // operator-supplied creds. CURSOR_CONVERSATION_ID unset → skip entirely
195
+ // (Claude Code / Hermes: zero change). A hit with a missing profile file
196
+ // is a declared identity (source grok-bind) so decideIdentity hard-errors
197
+ // rather than falling through to a sibling bot.
198
+ const grokToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
199
+ const conversationId = process.env.CURSOR_CONVERSATION_ID;
200
+ let binds: Record<string, string> = {};
201
+ if (!grokToken && conversationId) {
202
+ const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
203
+ if (existsSync(bindPath)) {
204
+ try {
205
+ const parsed = parseGrokBindsText(readFileSync(bindPath, "utf-8"));
206
+ binds = parsed.binds;
207
+ if (parsed.malformed) {
208
+ process.stderr.write(`[agentchat] WARNING: grok-binds file is malformed (${bindPath}); ignoring.\n`);
209
+ }
210
+ } catch {
211
+ binds = {};
212
+ }
213
+ }
214
+ }
215
+ const grok = decideGrokBind({
216
+ explicitIdentity: false,
217
+ hasToken: grokToken,
218
+ conversationId,
219
+ binds,
220
+ });
221
+ if (grok.kind === "hit") {
222
+ process.stderr.write(`[agentchat] grok-bind: ${grok.conversationId} → profile "${grok.profileName}"\n`);
223
+ return { path: nameToPath(grok.profileName), source: "grok-bind", declaredName: grok.profileName };
224
+ }
225
+ if (grok.kind === "miss") {
226
+ process.stderr.write(`[agentchat] no grok-bind matched CURSOR_CONVERSATION_ID=${grok.conversationId}\n`);
227
+ }
228
+
229
+ // 6. default — nothing was declared. NOT a licence to invent an identity.
172
230
  return { path: nameToPath("profile"), source: "default" };
173
231
  }
174
232
 
@@ -1938,6 +1996,75 @@ HANDLERS.set("transcribe", async (args) => {
1938
1996
  }
1939
1997
  });
1940
1998
 
1999
+
2000
+ /** Reload grok-binds.json (or AGENTCHAT_GROK_BINDS) for runtime lock/heal checks. */
2001
+ function loadGrokBinds(): Record<string, string> {
2002
+ const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
2003
+ if (!existsSync(bindPath)) return {};
2004
+ try {
2005
+ return parseGrokBindsText(readFileSync(bindPath, "utf-8")).binds;
2006
+ } catch {
2007
+ return {};
2008
+ }
2009
+ }
2010
+
2011
+ /**
2012
+ * Apply a profile file as the live identity and reconnect WS.
2013
+ * Shared by `switch_profile` and grok-bind heal.
2014
+ */
2015
+ function applyIdentityFromProfile(newProfile: any, targetFile: string): void {
2016
+ heartbeat.stop();
2017
+ if (backfillTimer) { clearTimeout(backfillTimer); backfillTimer = null; }
2018
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2019
+ if (ws) {
2020
+ ws.onclose = null;
2021
+ try { ws.close(); } catch {}
2022
+ ws = null;
2023
+ }
2024
+ sessionId = null;
2025
+ AGENT_ID = newProfile.agent_id;
2026
+ TOKEN = newProfile.token || "dev-token";
2027
+ CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
2028
+ profile = newProfile;
2029
+ activeProfileFile = targetFile;
2030
+ anonymousMode = false;
2031
+ wsReconnectAttempt = 0;
2032
+ heartbeat.start();
2033
+ connectWS();
2034
+ }
2035
+
2036
+ /**
2037
+ * If CURSOR_CONVERSATION_ID is bound and live identity drifted (e.g. another
2038
+ * agent called switch_profile on this shared MCP), force-reload the bound
2039
+ * profile before outbound writes. Logs to stderr when healing.
2040
+ */
2041
+ function ensureGrokBoundIdentity(): void {
2042
+ const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, loadGrokBinds());
2043
+ if (!boundName) return;
2044
+ const boundPath = nameToPath(boundName);
2045
+ if (!existsSync(boundPath)) return;
2046
+ let boundProfile: any;
2047
+ try {
2048
+ boundProfile = JSON.parse(readFileSync(boundPath, "utf-8"));
2049
+ } catch {
2050
+ return;
2051
+ }
2052
+ const liveName = profileNameFromPath(activeProfileFile);
2053
+ if (!shouldHealBoundIdentity({
2054
+ boundProfileName: boundName,
2055
+ liveProfileName: liveName,
2056
+ liveAgentId: AGENT_ID,
2057
+ boundAgentId: boundProfile?.agent_id,
2058
+ })) {
2059
+ return;
2060
+ }
2061
+ process.stderr.write(
2062
+ `[agentchat] grok-bind heal: live profile=${liveName ?? "?"} agent=${AGENT_ID || "?"} → bound "${boundName}" (${boundProfile.agent_id || "?"})\n`,
2063
+ );
2064
+ applyIdentityFromProfile(boundProfile, boundPath);
2065
+ }
2066
+
2067
+
1941
2068
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1942
2069
  let { name, arguments: args } = request.params;
1943
2070
  let viaExtendedCompat = false;
@@ -1954,6 +2081,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1954
2081
  if (argErr) return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
1955
2082
  }
1956
2083
 
2084
+ // Shared Cursor MCP: heal back to grok-bind identity if another agent
2085
+ // stole the live profile via switch_profile. No-op when unbound.
2086
+ ensureGrokBoundIdentity();
2087
+
1957
2088
  if (name === "list_skills") {
1958
2089
  const { chat_id } = (args || {}) as { chat_id?: string };
1959
2090
  const out: any = {
@@ -3054,6 +3185,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3054
3185
  return { content: [{ type: "text", text: `Current: ${current}\nAvailable profiles:\n${list}` }] };
3055
3186
  }
3056
3187
 
3188
+ // Shared Cursor MCP: grok-binds lock outbound identity — via conversation id
3189
+ // when set, or via current profile when Cursor stdio started with --profile
3190
+ // and no CURSOR_CONVERSATION_ID. Refuse switches that leave the locked
3191
+ // identity (including other Grok bots or Hermes/Spiral). No-op allowed.
3192
+ const switchGate = gateSwitchProfile({
3193
+ conversationId: process.env.CURSOR_CONVERSATION_ID,
3194
+ binds: loadGrokBinds(),
3195
+ requestedProfileName: profile_name,
3196
+ currentProfileName: profileNameFromPath(activeProfileFile),
3197
+ });
3198
+ if (switchGate.kind === "locked") {
3199
+ return { content: [{ type: "text", text: switchGate.message }], isError: true };
3200
+ }
3201
+
3057
3202
  // Find and load the profile
3058
3203
  const targetFile = nameToPath(profile_name);
3059
3204
  if (!existsSync(targetFile)) {
@@ -3061,34 +3206,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3061
3206
  }
3062
3207
 
3063
3208
  const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
3064
-
3065
- // Stop heartbeat first to prevent race with old connection
3066
- heartbeat.stop();
3067
- // Cancel any pending backfill / reconnect from the old identity.
3068
- if (backfillTimer) { clearTimeout(backfillTimer); backfillTimer = null; }
3069
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
3070
-
3071
- // Close old connection, disable its reconnect handler
3072
- if (ws) {
3073
- ws.onclose = null;
3074
- try { ws.close(); } catch {}
3075
- ws = null;
3076
- }
3077
- sessionId = null;
3078
-
3079
- // Update identity. activeProfileFile must move too — whoami reports it, and a
3080
- // stale value there makes the identity probe lie about which profile is live.
3081
- AGENT_ID = newProfile.agent_id;
3082
- TOKEN = newProfile.token || "dev-token";
3083
- CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
3084
- profile = newProfile;
3085
- activeProfileFile = targetFile;
3086
- anonymousMode = false;
3087
-
3088
- // Restart heartbeat and connect with new identity
3089
- wsReconnectAttempt = 0;
3090
- heartbeat.start();
3091
- connectWS();
3209
+ applyIdentityFromProfile(newProfile, targetFile);
3092
3210
 
3093
3211
  return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...` }] };
3094
3212
  }