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 +50 -1
- package/connector/backfill.ts +41 -0
- package/dist/server.js +228 -34
- package/package.json +3 -1
- package/src/grok-bind.ts +193 -0
- package/src/identity.ts +4 -1
- package/src/server.ts +148 -30
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.
|
|
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 =
|
|
668
|
-
var legacyConfigDir =
|
|
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) =>
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|
package/src/grok-bind.ts
ADDED
|
@@ -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.
|
|
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
|
-
|
|
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
|
}
|