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/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
  }