agentschat-mcp 0.33.3 → 0.33.7

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/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.7",
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,8 @@ var package_default = {
437
539
  "connector/identities.ts",
438
540
  "connector/server.ts",
439
541
  "connector/run.ts",
542
+ "connector/backfill.ts",
543
+ "connector/ingest.ts",
440
544
  "connector/README.md",
441
545
  "skills/onboarding.md",
442
546
  "dist/server.js",
@@ -451,7 +555,7 @@ import {
451
555
  ListToolsRequestSchema
452
556
  } from "@modelcontextprotocol/sdk/types.js";
453
557
  import { readFileSync as readFileSync2, existsSync as existsSync2, writeFileSync as writeFileSync3, mkdirSync, readdirSync } from "fs";
454
- import { join, dirname } from "path";
558
+ import { join as join2, dirname } from "path";
455
559
 
456
560
  // src/profile-store.ts
457
561
  import { existsSync, writeFileSync, renameSync, chmodSync, unlinkSync, statSync } from "fs";
@@ -653,6 +757,13 @@ Wake a host that has no channel-notification surface (Grok Bot, generic MCP clie
653
757
  ~/.grok/gateway.json, /home/box/sand-data/gateway.json)
654
758
  AGENTCHAT_GROK_AGENT_ID the Grok gateway agent uuid to wake (1:1 binding)
655
759
 
760
+ Grok multi-bot identity bind (Cursor / Grok Bot, no --profile):
761
+ ~/.agentschat/grok-binds.json maps CURSOR_CONVERSATION_ID (Grok uuid) \u2192 profile name.
762
+ AGENTCHAT_GROK_BINDS overrides that path. Profile names resolve like --profile.
763
+ Auto-bind does NOT set AGENTCHAT_WAKE_MODE \u2014 a Cursor-tool MCP should unset it
764
+ (per-identity wake daemons already POST sendPrompt). If the operator set
765
+ WAKE_MODE, it is left as-is.
766
+
656
767
  Hermes relay connector (no Hermes patch): run with --connector. See --connector --help.
657
768
 
658
769
  Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
@@ -664,14 +775,14 @@ Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
664
775
  }
665
776
  var cliArgs = parseArgs();
666
777
  var homeDir = process.env.HOME || process.env.USERPROFILE || ".";
667
- var configDir = join(homeDir, ".agentschat");
668
- var legacyConfigDir = join(homeDir, ".agentchat");
778
+ var configDir = join2(homeDir, ".agentschat");
779
+ var legacyConfigDir = join2(homeDir, ".agentchat");
669
780
  var profileDirs = [configDir, legacyConfigDir];
670
781
  function profileNameToPaths(name) {
671
782
  if (name.includes("/") || name.includes("\\"))
672
783
  return [name];
673
784
  const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
674
- return profileDirs.map((dir) => join(dir, `${safeName}.json`));
785
+ return profileDirs.map((dir) => join2(dir, `${safeName}.json`));
675
786
  }
676
787
  function nameToPath(name) {
677
788
  const candidates = profileNameToPaths(name);
@@ -686,11 +797,13 @@ function listProfileFiles() {
686
797
  files = readdirSync(dir).filter((f) => f.endsWith(".json"));
687
798
  } catch {}
688
799
  for (const file of files) {
800
+ if (file === DEFAULT_GROK_BINDS_FILENAME)
801
+ continue;
689
802
  const name = file.replace(/\.json$/, "");
690
803
  if (seen.has(name))
691
804
  continue;
692
805
  seen.add(name);
693
- profiles.push({ name, path: join(dir, file) });
806
+ profiles.push({ name, path: join2(dir, file) });
694
807
  }
695
808
  }
696
809
  return profiles;
@@ -704,6 +817,39 @@ function resolveProfile() {
704
817
  return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
705
818
  if (cliArgs.name)
706
819
  return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
820
+ const grokToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
821
+ const conversationId = process.env.CURSOR_CONVERSATION_ID;
822
+ let binds = {};
823
+ if (!grokToken && conversationId) {
824
+ const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
825
+ if (existsSync2(bindPath)) {
826
+ try {
827
+ const parsed = parseGrokBindsText(readFileSync2(bindPath, "utf-8"));
828
+ binds = parsed.binds;
829
+ if (parsed.malformed) {
830
+ process.stderr.write(`[agentchat] WARNING: grok-binds file is malformed (${bindPath}); ignoring.
831
+ `);
832
+ }
833
+ } catch {
834
+ binds = {};
835
+ }
836
+ }
837
+ }
838
+ const grok = decideGrokBind({
839
+ explicitIdentity: false,
840
+ hasToken: grokToken,
841
+ conversationId,
842
+ binds
843
+ });
844
+ if (grok.kind === "hit") {
845
+ process.stderr.write(`[agentchat] grok-bind: ${grok.conversationId} \u2192 profile "${grok.profileName}"
846
+ `);
847
+ return { path: nameToPath(grok.profileName), source: "grok-bind", declaredName: grok.profileName };
848
+ }
849
+ if (grok.kind === "miss") {
850
+ process.stderr.write(`[agentchat] no grok-bind matched CURSOR_CONVERSATION_ID=${grok.conversationId}
851
+ `);
852
+ }
707
853
  return { path: nameToPath("profile"), source: "default" };
708
854
  }
709
855
  var { path: profileFile, source: profileSource, declaredName } = resolveProfile();
@@ -2339,6 +2485,70 @@ HANDLERS.set("transcribe", async (args) => {
2339
2485
  return { content: [{ type: "text", text: `Error transcribing: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2340
2486
  }
2341
2487
  });
2488
+ function loadGrokBinds() {
2489
+ const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
2490
+ if (!existsSync2(bindPath))
2491
+ return {};
2492
+ try {
2493
+ return parseGrokBindsText(readFileSync2(bindPath, "utf-8")).binds;
2494
+ } catch {
2495
+ return {};
2496
+ }
2497
+ }
2498
+ function applyIdentityFromProfile(newProfile, targetFile) {
2499
+ heartbeat.stop();
2500
+ if (backfillTimer) {
2501
+ clearTimeout(backfillTimer);
2502
+ backfillTimer = null;
2503
+ }
2504
+ if (reconnectTimer) {
2505
+ clearTimeout(reconnectTimer);
2506
+ reconnectTimer = null;
2507
+ }
2508
+ if (ws) {
2509
+ ws.onclose = null;
2510
+ try {
2511
+ ws.close();
2512
+ } catch {}
2513
+ ws = null;
2514
+ }
2515
+ sessionId = null;
2516
+ AGENT_ID = newProfile.agent_id;
2517
+ TOKEN = newProfile.token || "dev-token";
2518
+ CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
2519
+ profile = newProfile;
2520
+ activeProfileFile = targetFile;
2521
+ anonymousMode = false;
2522
+ wsReconnectAttempt = 0;
2523
+ heartbeat.start();
2524
+ connectWS();
2525
+ }
2526
+ function ensureGrokBoundIdentity() {
2527
+ const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, loadGrokBinds());
2528
+ if (!boundName)
2529
+ return;
2530
+ const boundPath = nameToPath(boundName);
2531
+ if (!existsSync2(boundPath))
2532
+ return;
2533
+ let boundProfile;
2534
+ try {
2535
+ boundProfile = JSON.parse(readFileSync2(boundPath, "utf-8"));
2536
+ } catch {
2537
+ return;
2538
+ }
2539
+ const liveName = profileNameFromPath(activeProfileFile);
2540
+ if (!shouldHealBoundIdentity({
2541
+ boundProfileName: boundName,
2542
+ liveProfileName: liveName,
2543
+ liveAgentId: AGENT_ID,
2544
+ boundAgentId: boundProfile?.agent_id
2545
+ })) {
2546
+ return;
2547
+ }
2548
+ process.stderr.write(`[agentchat] grok-bind heal: live profile=${liveName ?? "?"} agent=${AGENT_ID || "?"} \u2192 bound "${boundName}" (${boundProfile.agent_id || "?"})
2549
+ `);
2550
+ applyIdentityFromProfile(boundProfile, boundPath);
2551
+ }
2342
2552
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2343
2553
  let { name, arguments: args } = request.params;
2344
2554
  let viaExtendedCompat = false;
@@ -2348,6 +2558,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2348
2558
  if (argErr)
2349
2559
  return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
2350
2560
  }
2561
+ ensureGrokBoundIdentity();
2351
2562
  if (name === "list_skills") {
2352
2563
  const { chat_id } = args || {};
2353
2564
  const out = {
@@ -3444,37 +3655,21 @@ ${list}` }] };
3444
3655
  Available profiles:
3445
3656
  ${list}` }] };
3446
3657
  }
3658
+ const switchGate = gateSwitchProfile({
3659
+ conversationId: process.env.CURSOR_CONVERSATION_ID,
3660
+ binds: loadGrokBinds(),
3661
+ requestedProfileName: profile_name,
3662
+ currentProfileName: profileNameFromPath(activeProfileFile)
3663
+ });
3664
+ if (switchGate.kind === "locked") {
3665
+ return { content: [{ type: "text", text: switchGate.message }], isError: true };
3666
+ }
3447
3667
  const targetFile = nameToPath(profile_name);
3448
3668
  if (!existsSync2(targetFile)) {
3449
3669
  return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }], isError: true };
3450
3670
  }
3451
3671
  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();
3672
+ applyIdentityFromProfile(newProfile, targetFile);
3478
3673
  return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...` }] };
3479
3674
  }
3480
3675
  if (name === "list_channel_docs") {
@@ -3824,7 +4019,7 @@ ${list}` }] };
3824
4019
  return { content: [{ type: "text", text: `${name} failed: ${String(e?.message || e).slice(0, 300)}` }], isError: true };
3825
4020
  }
3826
4021
  });
3827
- var mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
4022
+ var mentionTsFile = join2(configDir, `mention-ts-${AGENT_ID}.json`);
3828
4023
  function loadMentionTimestamps() {
3829
4024
  return loadCursor(mentionTsFile, safeStderrWrite);
3830
4025
  }
@@ -3832,7 +4027,7 @@ function saveMentionTimestamps(m) {
3832
4027
  persistCursor(mentionTsFile, m, safeStderrWrite);
3833
4028
  }
3834
4029
  var lastMentionTimestamp = loadMentionTimestamps();
3835
- var lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
4030
+ var lastSeenMessageTsFile = join2(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3836
4031
  function loadLastSeenMessageTs() {
3837
4032
  return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
3838
4033
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.33.3",
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.",
4
+ "version": "0.33.7",
5
+ "description": "Connect Claude Code to AgentsChat \u2014 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": {
8
8
  "agentschat-mcp": "src/cli.mjs",
@@ -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,8 @@
77
78
  "connector/identities.ts",
78
79
  "connector/server.ts",
79
80
  "connector/run.ts",
81
+ "connector/backfill.ts",
82
+ "connector/ingest.ts",
80
83
  "connector/README.md",
81
84
  "skills/onboarding.md",
82
85
  "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. */