agentschat-mcp 0.29.0 → 0.30.0

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
@@ -4,16 +4,14 @@
4
4
  * 像 weixin 插件一样:WebSocket 消息 → MCP channel notification → Claude Code 对话
5
5
  */
6
6
 
7
- // Runtime guard: this plugin ships its TypeScript entrypoint directly and
8
- // relies on Bun's global WebSocket, so it must run under Bun (launch via
9
- // `bunx agentschat-mcp`, not npx/node). Fail fast with a clear message rather
10
- // than a cryptic parse/"WebSocket is not defined" error downstream.
11
- if (typeof (globalThis as any).Bun === "undefined") {
12
- process.stderr.write(
13
- "[agentchat] agentschat-mcp requires the Bun runtime. Install Bun (https://bun.sh) and launch with `bunx agentschat-mcp` (not npx/node).\n",
14
- );
15
- process.exit(1);
16
- }
7
+ // Runtime: Bun is the primary/first-class runtime (`bunx agentschat-mcp` runs
8
+ // this .ts source directly, no build step). It ALSO runs on plain Node via the
9
+ // prebuilt bundle in dist/ (see src/cli.mjs launcher) so directory/registry
10
+ // tooling that introspects over stdio with Node/npx (e.g. Glama) can install
11
+ // and list tools. All Bun-specific I/O was replaced with node:fs; the WebSocket
12
+ // connect is wrapped in try/catch, so a missing global WebSocket (older Node)
13
+ // degrades to a reconnect log rather than crashing stdio/tools-list. Node 22+
14
+ // (global WebSocket/fetch/FormData) gets full functionality.
17
15
 
18
16
  // ── Proxy bypass ──────────────────────────────────────────────────
19
17
  // MCP subprocess inherits the parent's HTTP_PROXY/HTTPS_PROXY which
@@ -42,6 +40,8 @@ import { messageDedupKey, MessageDedup } from "./dedup.ts";
42
40
  import { computeReconnectDelay } from "./reconnect.ts";
43
41
  import { normalizeTimestampForCursor } from "./timestamps.ts";
44
42
  import { validateToolArgs } from "./argcheck.ts";
43
+ import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
44
+ import type { ProfileSource } from "./identity.ts";
45
45
  import pkg from "../package.json";
46
46
  import {
47
47
  CallToolRequestSchema,
@@ -49,16 +49,13 @@ import {
49
49
  } from "@modelcontextprotocol/sdk/types.js";
50
50
 
51
51
  // --- Config: CLI args > env vars > profile file > defaults ---
52
- import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync, readdirSync } from "fs";
52
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
53
53
  import { join, dirname } from "path";
54
-
55
- /** Atomic write: write to .tmp then rename (prevents corrupted profile on crash) */
56
- function safeWriteProfile(path: string, data: any) {
57
- const tmp = path + ".tmp";
58
- writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
59
- renameSync(tmp, path);
60
- try { chmodSync(path, 0o600); } catch {}
61
- }
54
+ // Atomic + 0600-by-construction profile writer. Lives in its own module so the
55
+ // "the key is never world-readable, even mid-write" property is directly testable.
56
+ import { safeWriteProfile } from "./profile-store.ts";
57
+ // Read-cursor persistence: a state change that merely *lives* in the teardown path.
58
+ import { flushCursor, loadCursor, persistCursor } from "./read-cursor.ts";
62
59
  import { randomUUID } from "crypto";
63
60
 
64
61
  function parseArgs() {
@@ -71,6 +68,8 @@ function parseArgs() {
71
68
  else if (args[i] === "--token" && args[i + 1]) parsed.token = args[++i];
72
69
  else if (args[i] === "--caps" && args[i + 1]) parsed.caps = args[++i];
73
70
  else if (args[i] === "--profile" && args[i + 1]) parsed.profile = args[++i];
71
+ // Boolean flag: explicit opt-in to creating a NEW account (see src/identity.ts).
72
+ else if (args[i] === "--register") parsed.register = "1";
74
73
  }
75
74
  return parsed;
76
75
  }
@@ -82,14 +81,19 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
82
81
  claude --dangerously-load-development-channels server:agentschat
83
82
 
84
83
  Options:
85
- --name <name> Display name (also used as profile name)
84
+ --name <name> Display name (also used as profile name). Registers a NEW agent
85
+ if no profile exists for it.
86
86
  --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
87
+ --register Explicitly opt in to registering a new agent (implied by --name)
87
88
  --id <id> Agent ID (default: auto-generated)
88
89
  --url <url> Server URL (default: production)
89
- --token <token> Auth token (default: auto-registered)
90
+ --token <token> Auth token (skips registration entirely)
90
91
  --caps <a,b,c> Capabilities (comma-separated)
91
92
  -h, --help Show this help
92
93
 
94
+ Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
95
+ no token, the server runs ANONYMOUS (lists tools, but never registers an account).
96
+
93
97
  Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
94
98
  Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
95
99
  process.exit(0);
@@ -135,20 +139,30 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
135
139
  return profiles;
136
140
  }
137
141
 
138
- function resolveProfilePath(): string {
142
+ function resolveProfile(): { path: string; source: ProfileSource; declaredName?: string } {
139
143
  // 1. AGENTSCHAT_PROFILE env var (supports both name and full path)
140
- if (process.env.AGENTSCHAT_PROFILE) return nameToPath(process.env.AGENTSCHAT_PROFILE);
144
+ if (process.env.AGENTSCHAT_PROFILE)
145
+ return { path: nameToPath(process.env.AGENTSCHAT_PROFILE), source: "env", declaredName: process.env.AGENTSCHAT_PROFILE };
141
146
  // 2. AGENTCHAT_PROFILE env var (legacy alias)
142
- if (process.env.AGENTCHAT_PROFILE) return nameToPath(process.env.AGENTCHAT_PROFILE);
147
+ if (process.env.AGENTCHAT_PROFILE)
148
+ return { path: nameToPath(process.env.AGENTCHAT_PROFILE), source: "legacy-env", declaredName: process.env.AGENTCHAT_PROFILE };
143
149
  // 3. --profile <name>
144
- if (cliArgs.profile) return nameToPath(cliArgs.profile);
150
+ if (cliArgs.profile) return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
145
151
  // 4. --name <name>
146
- if (cliArgs.name) return nameToPath(cliArgs.name);
147
- // 5. default
148
- return nameToPath("profile");
152
+ if (cliArgs.name) return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
153
+ // 5. default — nothing was declared. NOT a licence to invent an identity.
154
+ return { path: nameToPath("profile"), source: "default" };
149
155
  }
150
156
 
151
- const profileFile = resolveProfilePath();
157
+ const { path: profileFile, source: profileSource, declaredName } = resolveProfile();
158
+ /**
159
+ * The profile file currently in effect. Mutable because `switch_profile` swaps
160
+ * identity at runtime — `whoami` must report the live one, not the boot-time one.
161
+ * null = no profile is backing this session (anonymous, or token-only).
162
+ */
163
+ let activeProfileFile: string | null = profileFile;
164
+ /** No identity at all: serve tools/list, register nothing, connect nothing. */
165
+ let anonymousMode = false;
152
166
  let profile: any = {};
153
167
 
154
168
  const DEFAULT_SERVER = "https://agents-chat.com";
@@ -159,14 +173,82 @@ const WS_URL = process.env.AGENTCHAT_URL || (() => {
159
173
  })();
160
174
  const REST_URL = serverUrl;
161
175
 
162
- if (existsSync(profileFile)) {
176
+ // Identity, declared (not just hoisted) BEFORE anything can call apiFetch. These are
177
+ // filled in from `profile` once the identity block below has run. Registration runs
178
+ // before that and must not read them through the temporal dead zone — see below.
179
+ let AGENT_ID = "";
180
+ let TOKEN = "";
181
+ let CAPABILITIES: string[] = [];
182
+
183
+ // Native fetch, captured before the file-wide call-site rename to apiFetch so the
184
+ // wrapper below can't recurse into itself.
185
+ const nativeFetch = fetch;
186
+ // All AgentsChat REST goes through apiFetch so every call gets, from one place:
187
+ // (1) a timeout — a hung hub call must never block a tool forever; and
188
+ // (2) the bearer token, injected only when absent and TOKEN is set (so the few
189
+ // conditional-auth sites keep their exact semantics). init is otherwise passed
190
+ // through untouched, so callers keep using r.ok / r.text() / r.json().
191
+ //
192
+ // This must be defined ABOVE the registration block. apiFetch is a hoisted function,
193
+ // but its `timeoutMs = REST_TIMEOUT_MS` default and its `TOKEN` read are evaluated at
194
+ // CALL time: when the bare-fetch→apiFetch refactor moved these call sites above the
195
+ // const declarations, every /api/account/register call threw a TDZ ReferenceError that
196
+ // the surrounding `catch` reported as "Server unreachable" — silently disabling
197
+ // registration (and its dev-token migration twin, whose catch is empty). Registration
198
+ // happens before TOKEN is assigned, so TOKEN is "" there and no Authorization header is
199
+ // sent — exactly what the register endpoint expects.
200
+ const REST_TIMEOUT_MS = 15_000;
201
+ async function apiFetch(
202
+ input: string | URL,
203
+ init: RequestInit = {},
204
+ timeoutMs = REST_TIMEOUT_MS,
205
+ ): Promise<Response> {
206
+ const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
207
+ if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
208
+ const controller = new AbortController();
209
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
210
+ try {
211
+ return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
212
+ } finally {
213
+ clearTimeout(timer);
214
+ }
215
+ }
216
+
217
+ const hasToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
218
+ const identity = decideIdentity({
219
+ profileExists: existsSync(profileFile),
220
+ source: profileSource,
221
+ profileFile,
222
+ cliName: cliArgs.name,
223
+ declaredName,
224
+ registerFlag: !!cliArgs.register,
225
+ hasToken,
226
+ fallbackName: `Claude-${randomUUID().slice(0, 6)}`,
227
+ });
228
+
229
+ if (identity.mode === "profile") {
163
230
  profile = JSON.parse(readFileSync(profileFile, "utf-8"));
164
231
  process.stderr.write(`[agentchat] Profile loaded: ${profileFile}\n`);
232
+ } else if (identity.mode === "env-creds") {
233
+ // Token handed to us directly — authenticate with it, register nothing, write nothing.
234
+ activeProfileFile = null;
235
+ process.stderr.write(`[agentchat] Using credentials from environment — not registering.\n`);
236
+ } else if (identity.mode === "error") {
237
+ // A declared identity with no profile behind it. Inventing one is what corrupted
238
+ // attribution before, so fail loudly instead. Introspection never lands here.
239
+ process.stderr.write(`[agentchat] ERROR: ${identity.message}\n`);
240
+ process.exit(1);
241
+ } else if (identity.mode === "anonymous") {
242
+ // Nothing declared. Stay alive so stdio introspection (initialize/tools/list) works,
243
+ // but create no account and persist no credentials.
244
+ anonymousMode = true;
245
+ activeProfileFile = null;
246
+ process.stderr.write(`[agentchat] ${identity.reason}\n`);
165
247
  } else {
166
- // First run: auto-register with server to get a real agent key
167
- const displayName = cliArgs.name || `Claude-${randomUUID().slice(0, 6)}`;
248
+ // Explicit opt-in: register a real account and persist it.
249
+ const displayName = identity.displayName;
168
250
  const caps = ["claude-code", "coding", "chat"];
169
- process.stderr.write(`[agentchat] First run — registering with server...\n`);
251
+ process.stderr.write(`[agentchat] Registering "${displayName}" with server...\n`);
170
252
  try {
171
253
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
172
254
  method: "POST",
@@ -190,7 +272,11 @@ if (existsSync(profileFile)) {
190
272
  profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
191
273
  }
192
274
  } catch (e) {
193
- process.stderr.write(`[agentchat] Server unreachable, using local profile\n`);
275
+ // Always print the cause. This catch used to report EVERY failure as "Server
276
+ // unreachable" — including the TDZ ReferenceError above, which silently disabled
277
+ // registration for a week while pointing operators at their network. A failure path
278
+ // that fabricates a plausible diagnosis is worse than one that says nothing.
279
+ process.stderr.write(`[agentchat] Registration failed: ${e} — using local profile\n`);
194
280
  profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
195
281
  }
196
282
  mkdirSync(dirname(profileFile), { recursive: true });
@@ -198,8 +284,15 @@ if (existsSync(profileFile)) {
198
284
  process.stderr.write(`[agentchat] Profile saved: ${profileFile}\n`);
199
285
  }
200
286
 
201
- // Migrate old profiles with dev-token: auto-register to get real key
202
- if (profile.token === "dev-token") {
287
+ // Second auto-register trigger: a loaded profile still carrying the `dev-token`
288
+ // placeholder. Healing it is intended for a declared identity, but on the bare
289
+ // shared default path it mints an anonymous account just like the first trigger.
290
+ if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSource, hasToken, registerFlag: !!cliArgs.register })) {
291
+ process.stderr.write(
292
+ `[agentchat] Profile at ${profileFile} carries a dev-token but no identity was declared — ` +
293
+ `refusing to auto-register. Pass --name <name> or --register to create a real agent.\n`,
294
+ );
295
+ } else if (profile.token === "dev-token") {
203
296
  process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
204
297
  try {
205
298
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
@@ -228,38 +321,17 @@ if (profile.token === "dev-token") {
228
321
  process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
229
322
  }
230
323
  }
231
- } catch {}
232
- }
233
-
234
- let AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
235
- let TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
236
- let CAPABILITIES: string[] = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
237
-
238
- // Native fetch, captured before the file-wide call-site rename to apiFetch so the
239
- // wrapper below can't recurse into itself.
240
- const nativeFetch = fetch;
241
- // All AgentsChat REST goes through apiFetch so every call gets, from one place:
242
- // (1) a timeout — a hung hub call must never block a tool forever; and
243
- // (2) the bearer token, injected only when absent and TOKEN is set (so the few
244
- // conditional-auth sites keep their exact semantics). init is otherwise passed
245
- // through untouched, so callers keep using r.ok / r.text() / r.json().
246
- const REST_TIMEOUT_MS = 15_000;
247
- async function apiFetch(
248
- input: string | URL,
249
- init: RequestInit = {},
250
- timeoutMs = REST_TIMEOUT_MS,
251
- ): Promise<Response> {
252
- const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
253
- if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
254
- const controller = new AbortController();
255
- const timer = setTimeout(() => controller.abort(), timeoutMs);
256
- try {
257
- return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
258
- } finally {
259
- clearTimeout(timer);
324
+ } catch (e) {
325
+ // Was `catch {}`: it swallowed the same TDZ ReferenceError with no output at all.
326
+ process.stderr.write(`[agentchat] dev-token migration failed: ${e}\n`);
260
327
  }
261
328
  }
262
329
 
330
+ // Now that the identity block has settled `profile`, bind the runtime identity.
331
+ AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
332
+ TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
333
+ CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
334
+
263
335
  // Update display name if provided via CLI
264
336
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
265
337
  profile.display_name = cliArgs.name;
@@ -1610,13 +1682,14 @@ function mimeFromPath(p: string): string {
1610
1682
  }
1611
1683
  // Upload a local file via multipart POST /api/upload → { url, mime, size }.
1612
1684
  async function uploadLocalFile(path: string): Promise<{ url: string; mime: string; size: number }> {
1613
- const f = Bun.file(path);
1614
- if (!(await f.exists())) throw new Error(`file not found: ${path}`);
1615
- const mime = f.type && f.type !== "application/octet-stream" ? f.type : mimeFromPath(path);
1616
- const bytes = await f.arrayBuffer();
1685
+ // node:fs (not Bun.file) so this runs on plain Node too — Bun supports node:fs
1686
+ // identically, and the Node path is what registry introspection (Glama) uses.
1687
+ if (!existsSync(path)) throw new Error(`file not found: ${path}`);
1688
+ const mime = mimeFromPath(path);
1689
+ const buf = readFileSync(path);
1617
1690
  const name = path.split("/").pop() || "upload";
1618
1691
  const form = new FormData();
1619
- form.append("file", new Blob([bytes], { type: mime }), name);
1692
+ form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), name);
1620
1693
  // No Content-Type header → fetch derives the multipart boundary itself.
1621
1694
  const r = await apiFetch(`${REST_URL}/api/upload`, { method: "POST", headers: { "Authorization": `Bearer ${TOKEN}` }, body: form });
1622
1695
  const text = await r.text();
@@ -1624,7 +1697,7 @@ async function uploadLocalFile(path: string): Promise<{ url: string; mime: strin
1624
1697
  let data: any;
1625
1698
  try { data = JSON.parse(text); } catch { throw new Error(`upload returned non-JSON: ${text.slice(0, 120)}`); }
1626
1699
  if (!data?.url) throw new Error(`upload response missing url: ${text.slice(0, 120)}`);
1627
- return { url: data.url as string, mime: (data.type as string) || mime, size: (data.size as number) ?? bytes.byteLength };
1700
+ return { url: data.url as string, mime: (data.type as string) || mime, size: (data.size as number) ?? buf.byteLength };
1628
1701
  }
1629
1702
  // Post a message carrying exactly one media attachment (image|audio).
1630
1703
  async function sendMediaMessage(kind: "image" | "audio", args: any): Promise<{ content: any[]; isError?: boolean }> {
@@ -1981,15 +2054,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1981
2054
  if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }], isError: true };
1982
2055
  const currentVersion = Number(meta.version ?? 0);
1983
2056
  let cachedVersion: number | null = null;
1984
- try { cachedVersion = Number(JSON.parse(await Bun.file(pMeta).text()).version); } catch {}
2057
+ try { cachedVersion = Number(JSON.parse(readFileSync(pMeta, "utf8")).version); } catch {}
1985
2058
  if (cachedVersion !== null && cachedVersion === currentVersion) {
1986
2059
  return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} — no download. Read that file to run it.` }] };
1987
2060
  }
1988
2061
  const bodyR = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1989
2062
  if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
1990
2063
  const doc = JSON.parse(await bodyR.text());
1991
- await Bun.write(pMd, String(doc?.body_markdown ?? ""));
1992
- await Bun.write(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
2064
+ // node:fs (not Bun.write) — runs on plain Node too. Bun.write auto-creates
2065
+ // parent dirs; writeFileSync does not, so mkdir the cache dir first.
2066
+ mkdirSync(dirname(pMd), { recursive: true });
2067
+ writeFileSync(pMd, String(doc?.body_markdown ?? ""));
2068
+ writeFileSync(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
1993
2069
  return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} → ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
1994
2070
  } catch (e: any) {
1995
2071
  return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
@@ -2013,7 +2089,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2013
2089
  const currentVersion = Number(meta.version ?? 0);
2014
2090
  // 2. Local cache check — skip the body fetch if we already have this version.
2015
2091
  let cachedVersion: number | null = null;
2016
- try { cachedVersion = Number(JSON.parse(await Bun.file(metaPath).text()).version); } catch {}
2092
+ try { cachedVersion = Number(JSON.parse(readFileSync(metaPath, "utf8")).version); } catch {}
2017
2093
  if (cachedVersion !== null && cachedVersion === currentVersion) {
2018
2094
  return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} — no download. Read that file to run it.` }] };
2019
2095
  }
@@ -2022,9 +2098,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2022
2098
  if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }], isError: true };
2023
2099
  const doc = JSON.parse(await docR.text());
2024
2100
  const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
2025
- // 4. Write the local mirror + version sidecar.
2026
- await Bun.write(mdPath, body);
2027
- await Bun.write(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
2101
+ // 4. Write the local mirror + version sidecar (node:fs, not Bun.write, for
2102
+ // Node compat; mkdir the cache dir first since writeFileSync won't auto-create).
2103
+ mkdirSync(dirname(mdPath), { recursive: true });
2104
+ writeFileSync(mdPath, body);
2105
+ writeFileSync(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
2028
2106
  const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
2029
2107
  return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} → ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
2030
2108
  } catch (e: any) {
@@ -2710,7 +2788,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2710
2788
  } catch (e: any) {
2711
2789
  authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
2712
2790
  }
2713
- return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${profileFile}` }] };
2791
+ return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${activeProfileFile ?? (anonymousMode ? "(none — anonymous, no profile written)" : "(none — credentials from environment)")}` }] };
2714
2792
  }
2715
2793
 
2716
2794
  if (name === "list_channels") {
@@ -2903,11 +2981,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2903
2981
  }
2904
2982
  sessionId = null;
2905
2983
 
2906
- // Update identity
2984
+ // Update identity. activeProfileFile must move too — whoami reports it, and a
2985
+ // stale value there makes the identity probe lie about which profile is live.
2907
2986
  AGENT_ID = newProfile.agent_id;
2908
2987
  TOKEN = newProfile.token || "dev-token";
2909
2988
  CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
2910
2989
  profile = newProfile;
2990
+ activeProfileFile = targetFile;
2991
+ anonymousMode = false;
2911
2992
 
2912
2993
  // Restart heartbeat and connect with new identity
2913
2994
  wsReconnectAttempt = 0;
@@ -3282,15 +3363,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3282
3363
  // Persisted to disk so reconnect/restart doesn't lose state
3283
3364
  const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
3284
3365
  function loadMentionTimestamps(): Map<string, string> {
3285
- try {
3286
- const raw = readFileSync(mentionTsFile, "utf-8");
3287
- return new Map(Object.entries(JSON.parse(raw)));
3288
- } catch { return new Map(); }
3366
+ return loadCursor(mentionTsFile, safeStderrWrite);
3289
3367
  }
3368
+ // Re-attempted on the next mention, but a persistent failure (perms, full disk) would
3369
+ // retry just as silently forever — and the state is gone on restart. So it reports.
3290
3370
  function saveMentionTimestamps(m: Map<string, string>) {
3291
- try {
3292
- writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
3293
- } catch {}
3371
+ persistCursor(mentionTsFile, m, safeStderrWrite);
3294
3372
  }
3295
3373
  const lastMentionTimestamp = loadMentionTimestamps();
3296
3374
 
@@ -3311,36 +3389,29 @@ const lastMentionTimestamp = loadMentionTimestamps();
3311
3389
  // connect or reconnect; if we missed anything, we find it.
3312
3390
  const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3313
3391
  function loadLastSeenMessageTs(): Map<string, string> {
3314
- try {
3315
- const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
3316
- return new Map(Object.entries(JSON.parse(raw)));
3317
- } catch { return new Map(); }
3318
- }
3319
- function saveLastSeenMessageTs(m: Map<string, string>) {
3320
- try {
3321
- writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
3322
- } catch {}
3392
+ return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
3323
3393
  }
3324
3394
  const lastSeenMessageTs = loadLastSeenMessageTs();
3325
3395
  const cursorFlushIntervalMs = Math.max(
3326
3396
  500,
3327
3397
  Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
3328
3398
  );
3329
- let lastSeenMessageTsDirty = false;
3399
+ /** Dirty flag lives in an object so flushCursor can clear it only on a landed write. */
3400
+ const cursorState = { dirty: false };
3330
3401
  let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
3331
3402
 
3332
3403
  function flushLastSeenMessageTs() {
3333
- if (!lastSeenMessageTsDirty) return;
3334
- lastSeenMessageTsDirty = false;
3404
+ if (!cursorState.dirty) return;
3335
3405
  if (lastSeenMessageTsTimer) {
3336
3406
  clearTimeout(lastSeenMessageTsTimer);
3337
3407
  lastSeenMessageTsTimer = null;
3338
3408
  }
3339
- saveLastSeenMessageTs(lastSeenMessageTs);
3409
+ // Stays dirty if the write fails, so the shutdown fallback flush actually retries.
3410
+ flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
3340
3411
  }
3341
3412
 
3342
3413
  function scheduleLastSeenMessageTsSave() {
3343
- lastSeenMessageTsDirty = true;
3414
+ cursorState.dirty = true;
3344
3415
  if (lastSeenMessageTsTimer) return;
3345
3416
  lastSeenMessageTsTimer = setTimeout(() => {
3346
3417
  lastSeenMessageTsTimer = null;
@@ -3930,7 +4001,8 @@ function shutdownFromStdio(reason: string) {
3930
4001
  if (shuttingDown) return;
3931
4002
  shuttingDown = true;
3932
4003
  safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3933
- try { flushLastSeenMessageTs(); } catch {}
4004
+ // Persisting the read cursor is a state change, not teardown: report, never swallow.
4005
+ try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor flush failed on shutdown: ${e}\n`); }
3934
4006
  try { heartbeat.stop(); } catch {}
3935
4007
  try { stopAllTypingHeartbeats(); } catch {}
3936
4008
  if (reconnectTimer) {
@@ -3967,7 +4039,7 @@ function installStdioLifecycleGuards() {
3967
4039
  process.stderr.on("error", handleOutputError);
3968
4040
  process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
3969
4041
  process.on("beforeExit", () => {
3970
- try { flushLastSeenMessageTs(); } catch {}
4042
+ try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor fallback flush failed: ${e}\n`); }
3971
4043
  });
3972
4044
  }
3973
4045
 
@@ -4000,7 +4072,13 @@ async function checkVersionStaleness(): Promise<void> {
4000
4072
  // --- Start ---
4001
4073
  async function main() {
4002
4074
  installStdioLifecycleGuards();
4003
- connectWS();
4075
+ // Anonymous = no credentials to present. Connecting would just fail auth in a
4076
+ // reconnect loop; stdio (initialize/tools/list) is served either way.
4077
+ if (anonymousMode) {
4078
+ process.stderr.write(`[agentchat] Anonymous — not connecting to the hub.\n`);
4079
+ } else {
4080
+ connectWS();
4081
+ }
4004
4082
 
4005
4083
  // Stdio is the only supported transport. The --port HTTP SSE path was
4006
4084
  // removed in v0.6.7 — OpenClaw users should install the native channel