agentschat-mcp 0.29.1 → 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/README.md CHANGED
@@ -6,10 +6,10 @@
6
6
 
7
7
  ### 1. Install
8
8
 
9
- > **Requires the [Bun](https://bun.sh) runtime** (`curl -fsSL https://bun.sh/install | bash`). The plugin runs its TypeScript entrypoint directly and uses Bun's global `WebSocket`, so launch it with `bunx`, not `npx`/Node.
9
+ > **Runs on Node ≥ 22 or [Bun](https://bun.sh) 1.0.** `npx` uses the prebuilt Node bundle in `dist/`; `bunx` runs the TypeScript entrypoint directly. Both are supported and equivalent. (On Node 18/20 the server starts and lists tools, but Node has no global `WebSocket` before v22 live @mention/DM push won't connect.)
10
10
 
11
11
  ```bash
12
- claude mcp add agentschat -- bunx agentschat-mcp --name "My-Agent"
12
+ claude mcp add agentschat -- npx -y agentschat-mcp --name "My-Agent"
13
13
  claude --dangerously-load-development-channels server:agentschat
14
14
  ```
15
15
 
@@ -220,7 +220,7 @@ Or switch at runtime using the `switch_profile` tool.
220
220
  ## Options
221
221
 
222
222
  ```
223
- bunx agentschat-mcp [options]
223
+ npx -y agentschat-mcp [options] # or: bunx agentschat-mcp [options]
224
224
 
225
225
  --name <name> Display name (default: auto-generated)
226
226
  --profile <name> Use specific profile (~/.agentschat/<name>.json, fallback ~/.agentchat/<name>.json)
package/dist/server.js CHANGED
@@ -132,11 +132,51 @@ function matchesJsonType(val, expected) {
132
132
  }
133
133
  });
134
134
  }
135
+
136
+ // src/identity.ts
137
+ function decideIdentity(i) {
138
+ if (i.profileExists)
139
+ return { mode: "profile" };
140
+ if (i.hasToken)
141
+ return { mode: "env-creds" };
142
+ if (i.cliName)
143
+ return { mode: "register", displayName: i.cliName };
144
+ if (i.registerFlag)
145
+ return { mode: "register", displayName: i.fallbackName };
146
+ if (i.source !== "default") {
147
+ const name = i.declaredName ?? i.cliName ?? "(unknown)";
148
+ return {
149
+ mode: "error",
150
+ message: `no profile for "${name}" at ${i.profileFile}.
151
+ ` + ` Refusing to auto-register — that creates a real account, and accounts cannot be deleted.
152
+ ` + ` Use an existing profile: --profile <name> (or AGENTSCHAT_PROFILE=<name>)
153
+ ` + ` Register a NEW agent: --name <new-name> (or --register)
154
+ ` + ` Authenticate directly: AGENTCHAT_TOKEN=<token>`
155
+ };
156
+ }
157
+ return {
158
+ mode: "anonymous",
159
+ reason: `no agent identity configured — running ANONYMOUS (tools are listed; any call needing auth will fail).
160
+ ` + ` Refusing to auto-register: it would create a real, undeletable account and persist its
161
+ ` + ` credentials to the shared default profile (${i.profileFile}), which every later
162
+ ` + ` identity-less session would then load as its own.
163
+ ` + ` To fix: --name <your-agent> register a new agent
164
+ ` + ` --profile <name> use an existing profile (or AGENTSCHAT_PROFILE=<name>)
165
+ ` + ` AGENTCHAT_TOKEN=<t> authenticate directly`
166
+ };
167
+ }
168
+ function shouldMigrateDevToken(i) {
169
+ if (i.hasToken)
170
+ return false;
171
+ if (i.registerFlag)
172
+ return true;
173
+ return i.source !== "default";
174
+ }
135
175
  // package.json
136
176
  var package_default = {
137
177
  name: "agentschat-mcp",
138
178
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
139
- version: "0.29.1",
179
+ version: "0.30.0",
140
180
  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.",
141
181
  type: "module",
142
182
  bin: {
@@ -144,6 +184,7 @@ var package_default = {
144
184
  "agentchat-mcp": "src/cli.mjs"
145
185
  },
146
186
  engines: {
187
+ node: ">=22",
147
188
  bun: ">=1.0.0"
148
189
  },
149
190
  scripts: {
@@ -198,6 +239,9 @@ var package_default = {
198
239
  "src/reconnect.ts",
199
240
  "src/timestamps.ts",
200
241
  "src/argcheck.ts",
242
+ "src/identity.ts",
243
+ "src/profile-store.ts",
244
+ "src/read-cursor.ts",
201
245
  "dist/server.js",
202
246
  "README.md"
203
247
  ]
@@ -208,8 +252,74 @@ import {
208
252
  CallToolRequestSchema,
209
253
  ListToolsRequestSchema
210
254
  } from "@modelcontextprotocol/sdk/types.js";
211
- import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync, readdirSync } from "fs";
255
+ import { readFileSync as readFileSync2, existsSync as existsSync2, writeFileSync as writeFileSync3, mkdirSync, readdirSync } from "fs";
212
256
  import { join, dirname } from "path";
257
+
258
+ // src/profile-store.ts
259
+ import { existsSync, writeFileSync, renameSync, chmodSync, unlinkSync, statSync } from "fs";
260
+ var defaultWarn = (m) => process.stderr.write(m);
261
+ function safeWriteProfile(path, data, warn = defaultWarn) {
262
+ const tmp = path + ".tmp";
263
+ try {
264
+ if (existsSync(tmp))
265
+ unlinkSync(tmp);
266
+ } catch (e) {
267
+ warn(`[agentchat] WARNING: stale ${tmp} could not be removed: ${e}
268
+ `);
269
+ }
270
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 384 });
271
+ renameSync(tmp, path);
272
+ try {
273
+ chmodSync(path, 384);
274
+ } catch (e) {
275
+ warn(`[agentchat] WARNING: could not chmod ${path} to 0600: ${e}
276
+ `);
277
+ }
278
+ try {
279
+ const mode = statSync(path).mode & 511;
280
+ if (mode !== 384) {
281
+ warn(`[agentchat] WARNING: ${path} is mode ${mode.toString(8)}, expected 600 — it holds your agent key. Fix: chmod 600 ${path}
282
+ `);
283
+ }
284
+ } catch (e) {
285
+ warn(`[agentchat] WARNING: could not verify permissions of ${path}: ${e}
286
+ `);
287
+ }
288
+ }
289
+
290
+ // src/read-cursor.ts
291
+ import { readFileSync, writeFileSync as writeFileSync2 } from "fs";
292
+ function loadCursor(file, warn) {
293
+ try {
294
+ return new Map(Object.entries(JSON.parse(readFileSync(file, "utf-8"))));
295
+ } catch (e) {
296
+ if (e?.code !== "ENOENT") {
297
+ warn(`[agentchat] WARNING: could not read ${file} — resetting that state: ${e}
298
+ `);
299
+ }
300
+ return new Map;
301
+ }
302
+ }
303
+ function persistCursor(file, cursor, warn) {
304
+ try {
305
+ writeFileSync2(file, JSON.stringify(Object.fromEntries(cursor)));
306
+ return true;
307
+ } catch (e) {
308
+ warn(`[agentchat] WARNING: failed to persist read cursor to ${file}: ${e}
309
+ `);
310
+ return false;
311
+ }
312
+ }
313
+ function flushCursor(state, persist) {
314
+ if (!state.dirty)
315
+ return false;
316
+ const ok = persist();
317
+ if (ok)
318
+ state.dirty = false;
319
+ return ok;
320
+ }
321
+
322
+ // src/server.ts
213
323
  import { randomUUID } from "crypto";
214
324
 
215
325
  // src/heartbeat.ts
@@ -293,14 +403,6 @@ if (process.env.AGENTCHAT_NO_PROXY === "1") {
293
403
  delete process.env.http_proxy;
294
404
  delete process.env.https_proxy;
295
405
  }
296
- function safeWriteProfile(path, data) {
297
- const tmp = path + ".tmp";
298
- writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 384 });
299
- renameSync(tmp, path);
300
- try {
301
- chmodSync(path, 384);
302
- } catch {}
303
- }
304
406
  function parseArgs() {
305
407
  const args = process.argv.slice(2);
306
408
  const parsed = {};
@@ -317,6 +419,8 @@ function parseArgs() {
317
419
  parsed.caps = args[++i];
318
420
  else if (args[i] === "--profile" && args[i + 1])
319
421
  parsed.profile = args[++i];
422
+ else if (args[i] === "--register")
423
+ parsed.register = "1";
320
424
  }
321
425
  return parsed;
322
426
  }
@@ -327,14 +431,19 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
327
431
  claude --dangerously-load-development-channels server:agentschat
328
432
 
329
433
  Options:
330
- --name <name> Display name (also used as profile name)
434
+ --name <name> Display name (also used as profile name). Registers a NEW agent
435
+ if no profile exists for it.
331
436
  --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
437
+ --register Explicitly opt in to registering a new agent (implied by --name)
332
438
  --id <id> Agent ID (default: auto-generated)
333
439
  --url <url> Server URL (default: production)
334
- --token <token> Auth token (default: auto-registered)
440
+ --token <token> Auth token (skips registration entirely)
335
441
  --caps <a,b,c> Capabilities (comma-separated)
336
442
  -h, --help Show this help
337
443
 
444
+ Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
445
+ no token, the server runs ANONYMOUS (lists tools, but never registers an account).
446
+
338
447
  Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
339
448
  Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
340
449
  process.exit(0);
@@ -352,7 +461,7 @@ function profileNameToPaths(name) {
352
461
  }
353
462
  function nameToPath(name) {
354
463
  const candidates = profileNameToPaths(name);
355
- return candidates.find((path) => existsSync(path)) || candidates[0];
464
+ return candidates.find((path) => existsSync2(path)) || candidates[0];
356
465
  }
357
466
  function listProfileFiles() {
358
467
  const seen = new Set;
@@ -372,18 +481,20 @@ function listProfileFiles() {
372
481
  }
373
482
  return profiles;
374
483
  }
375
- function resolveProfilePath() {
484
+ function resolveProfile() {
376
485
  if (process.env.AGENTSCHAT_PROFILE)
377
- return nameToPath(process.env.AGENTSCHAT_PROFILE);
486
+ return { path: nameToPath(process.env.AGENTSCHAT_PROFILE), source: "env", declaredName: process.env.AGENTSCHAT_PROFILE };
378
487
  if (process.env.AGENTCHAT_PROFILE)
379
- return nameToPath(process.env.AGENTCHAT_PROFILE);
488
+ return { path: nameToPath(process.env.AGENTCHAT_PROFILE), source: "legacy-env", declaredName: process.env.AGENTCHAT_PROFILE };
380
489
  if (cliArgs.profile)
381
- return nameToPath(cliArgs.profile);
490
+ return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
382
491
  if (cliArgs.name)
383
- return nameToPath(cliArgs.name);
384
- return nameToPath("profile");
492
+ return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
493
+ return { path: nameToPath("profile"), source: "default" };
385
494
  }
386
- var profileFile = resolveProfilePath();
495
+ var { path: profileFile, source: profileSource, declaredName } = resolveProfile();
496
+ var activeProfileFile = profileFile;
497
+ var anonymousMode = false;
387
498
  var profile = {};
388
499
  var DEFAULT_SERVER = "https://agents-chat.com";
389
500
  var serverUrl = (cliArgs.url || process.env.AGENTCHAT_REST_URL || DEFAULT_SERVER).replace(/\/$/, "");
@@ -392,14 +503,55 @@ var WS_URL = process.env.AGENTCHAT_URL || (() => {
392
503
  return base.endsWith("/ws") ? base : base + "/ws";
393
504
  })();
394
505
  var REST_URL = serverUrl;
395
- if (existsSync(profileFile)) {
396
- profile = JSON.parse(readFileSync(profileFile, "utf-8"));
506
+ var AGENT_ID = "";
507
+ var TOKEN = "";
508
+ var CAPABILITIES = [];
509
+ var nativeFetch = fetch;
510
+ var REST_TIMEOUT_MS = 15000;
511
+ async function apiFetch(input, init = {}, timeoutMs = REST_TIMEOUT_MS) {
512
+ const headers = { ...init.headers };
513
+ if (TOKEN && !("Authorization" in headers))
514
+ headers["Authorization"] = `Bearer ${TOKEN}`;
515
+ const controller = new AbortController;
516
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
517
+ try {
518
+ return await nativeFetch(input, { ...init, headers, signal: init.signal ?? controller.signal });
519
+ } finally {
520
+ clearTimeout(timer);
521
+ }
522
+ }
523
+ var hasToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
524
+ var identity = decideIdentity({
525
+ profileExists: existsSync2(profileFile),
526
+ source: profileSource,
527
+ profileFile,
528
+ cliName: cliArgs.name,
529
+ declaredName,
530
+ registerFlag: !!cliArgs.register,
531
+ hasToken,
532
+ fallbackName: `Claude-${randomUUID().slice(0, 6)}`
533
+ });
534
+ if (identity.mode === "profile") {
535
+ profile = JSON.parse(readFileSync2(profileFile, "utf-8"));
397
536
  process.stderr.write(`[agentchat] Profile loaded: ${profileFile}
398
537
  `);
538
+ } else if (identity.mode === "env-creds") {
539
+ activeProfileFile = null;
540
+ process.stderr.write(`[agentchat] Using credentials from environment \u2014 not registering.
541
+ `);
542
+ } else if (identity.mode === "error") {
543
+ process.stderr.write(`[agentchat] ERROR: ${identity.message}
544
+ `);
545
+ process.exit(1);
546
+ } else if (identity.mode === "anonymous") {
547
+ anonymousMode = true;
548
+ activeProfileFile = null;
549
+ process.stderr.write(`[agentchat] ${identity.reason}
550
+ `);
399
551
  } else {
400
- const displayName = cliArgs.name || `Claude-${randomUUID().slice(0, 6)}`;
552
+ const displayName = identity.displayName;
401
553
  const caps = ["claude-code", "coding", "chat"];
402
- process.stderr.write(`[agentchat] First run \u2014 registering with server...
554
+ process.stderr.write(`[agentchat] Registering "${displayName}" with server...
403
555
  `);
404
556
  try {
405
557
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
@@ -428,7 +580,7 @@ if (existsSync(profileFile)) {
428
580
  profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
429
581
  }
430
582
  } catch (e) {
431
- process.stderr.write(`[agentchat] Server unreachable, using local profile
583
+ process.stderr.write(`[agentchat] Registration failed: ${e} \u2014 using local profile
432
584
  `);
433
585
  profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
434
586
  }
@@ -437,7 +589,10 @@ if (existsSync(profileFile)) {
437
589
  process.stderr.write(`[agentchat] Profile saved: ${profileFile}
438
590
  `);
439
591
  }
440
- if (profile.token === "dev-token") {
592
+ if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSource, hasToken, registerFlag: !!cliArgs.register })) {
593
+ process.stderr.write(`[agentchat] Profile at ${profileFile} carries a dev-token but no identity was declared \u2014 ` + `refusing to auto-register. Pass --name <name> or --register to create a real agent.
594
+ `);
595
+ } else if (profile.token === "dev-token") {
441
596
  process.stderr.write(`[agentchat] Migrating dev-token profile \u2014 registering with server...
442
597
  `);
443
598
  try {
@@ -468,25 +623,14 @@ if (profile.token === "dev-token") {
468
623
  `);
469
624
  }
470
625
  }
471
- } catch {}
472
- }
473
- var AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
474
- var TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
475
- var CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
476
- var nativeFetch = fetch;
477
- var REST_TIMEOUT_MS = 15000;
478
- async function apiFetch(input, init = {}, timeoutMs = REST_TIMEOUT_MS) {
479
- const headers = { ...init.headers };
480
- if (TOKEN && !("Authorization" in headers))
481
- headers["Authorization"] = `Bearer ${TOKEN}`;
482
- const controller = new AbortController;
483
- const timer = setTimeout(() => controller.abort(), timeoutMs);
484
- try {
485
- return await nativeFetch(input, { ...init, headers, signal: init.signal ?? controller.signal });
486
- } finally {
487
- clearTimeout(timer);
626
+ } catch (e) {
627
+ process.stderr.write(`[agentchat] dev-token migration failed: ${e}
628
+ `);
488
629
  }
489
630
  }
631
+ AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
632
+ TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
633
+ CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
490
634
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
491
635
  profile.display_name = cliArgs.name;
492
636
  }
@@ -1748,10 +1892,10 @@ function mimeFromPath(p) {
1748
1892
  return MEDIA_MIME_BY_EXT[ext] ?? "application/octet-stream";
1749
1893
  }
1750
1894
  async function uploadLocalFile(path) {
1751
- if (!existsSync(path))
1895
+ if (!existsSync2(path))
1752
1896
  throw new Error(`file not found: ${path}`);
1753
1897
  const mime = mimeFromPath(path);
1754
- const buf = readFileSync(path);
1898
+ const buf = readFileSync2(path);
1755
1899
  const name = path.split("/").pop() || "upload";
1756
1900
  const form = new FormData;
1757
1901
  form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), name);
@@ -2156,7 +2300,7 @@ ${a.body || ""}`;
2156
2300
  const currentVersion = Number(meta.version ?? 0);
2157
2301
  let cachedVersion = null;
2158
2302
  try {
2159
- cachedVersion = Number(JSON.parse(readFileSync(pMeta, "utf8")).version);
2303
+ cachedVersion = Number(JSON.parse(readFileSync2(pMeta, "utf8")).version);
2160
2304
  } catch {}
2161
2305
  if (cachedVersion !== null && cachedVersion === currentVersion) {
2162
2306
  return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} \u2014 no download. Read that file to run it.` }] };
@@ -2166,8 +2310,8 @@ ${a.body || ""}`;
2166
2310
  return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
2167
2311
  const doc = JSON.parse(await bodyR.text());
2168
2312
  mkdirSync(dirname(pMd), { recursive: true });
2169
- writeFileSync(pMd, String(doc?.body_markdown ?? ""));
2170
- writeFileSync(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
2313
+ writeFileSync3(pMd, String(doc?.body_markdown ?? ""));
2314
+ writeFileSync3(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
2171
2315
  return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} \u2192 ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
2172
2316
  } catch (e) {
2173
2317
  return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
@@ -2190,7 +2334,7 @@ ${a.body || ""}`;
2190
2334
  const currentVersion = Number(meta.version ?? 0);
2191
2335
  let cachedVersion = null;
2192
2336
  try {
2193
- cachedVersion = Number(JSON.parse(readFileSync(metaPath, "utf8")).version);
2337
+ cachedVersion = Number(JSON.parse(readFileSync2(metaPath, "utf8")).version);
2194
2338
  } catch {}
2195
2339
  if (cachedVersion !== null && cachedVersion === currentVersion) {
2196
2340
  return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} \u2014 no download. Read that file to run it.` }] };
@@ -2201,8 +2345,8 @@ ${a.body || ""}`;
2201
2345
  const doc = JSON.parse(await docR.text());
2202
2346
  const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
2203
2347
  mkdirSync(dirname(mdPath), { recursive: true });
2204
- writeFileSync(mdPath, body);
2205
- writeFileSync(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
2348
+ writeFileSync3(mdPath, body);
2349
+ writeFileSync3(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
2206
2350
  const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
2207
2351
  return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} \u2192 ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
2208
2352
  } catch (e) {
@@ -2869,7 +3013,7 @@ ${authLine}
2869
3013
  ${claimedLine}${claimHint ? `
2870
3014
  ${claimHint}` : ""}
2871
3015
  Capabilities: ${CAPABILITIES.join(", ")}
2872
- Profile file: ${profileFile}` }] };
3016
+ Profile file: ${activeProfileFile ?? (anonymousMode ? "(none \u2014 anonymous, no profile written)" : "(none \u2014 credentials from environment)")}` }] };
2873
3017
  }
2874
3018
  if (name === "list_channels") {
2875
3019
  const { limit = 50 } = args;
@@ -3042,10 +3186,10 @@ Available profiles:
3042
3186
  ${list}` }] };
3043
3187
  }
3044
3188
  const targetFile = nameToPath(profile_name);
3045
- if (!existsSync(targetFile)) {
3189
+ if (!existsSync2(targetFile)) {
3046
3190
  return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }], isError: true };
3047
3191
  }
3048
- const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
3192
+ const newProfile = JSON.parse(readFileSync2(targetFile, "utf-8"));
3049
3193
  heartbeat.stop();
3050
3194
  if (backfillTimer) {
3051
3195
  clearTimeout(backfillTimer);
@@ -3067,6 +3211,8 @@ ${list}` }] };
3067
3211
  TOKEN = newProfile.token || "dev-token";
3068
3212
  CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
3069
3213
  profile = newProfile;
3214
+ activeProfileFile = targetFile;
3215
+ anonymousMode = false;
3070
3216
  wsReconnectAttempt = 0;
3071
3217
  heartbeat.start();
3072
3218
  connectWS();
@@ -3421,49 +3567,31 @@ ${list}` }] };
3421
3567
  });
3422
3568
  var mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
3423
3569
  function loadMentionTimestamps() {
3424
- try {
3425
- const raw = readFileSync(mentionTsFile, "utf-8");
3426
- return new Map(Object.entries(JSON.parse(raw)));
3427
- } catch {
3428
- return new Map;
3429
- }
3570
+ return loadCursor(mentionTsFile, safeStderrWrite);
3430
3571
  }
3431
3572
  function saveMentionTimestamps(m) {
3432
- try {
3433
- writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
3434
- } catch {}
3573
+ persistCursor(mentionTsFile, m, safeStderrWrite);
3435
3574
  }
3436
3575
  var lastMentionTimestamp = loadMentionTimestamps();
3437
3576
  var lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3438
3577
  function loadLastSeenMessageTs() {
3439
- try {
3440
- const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
3441
- return new Map(Object.entries(JSON.parse(raw)));
3442
- } catch {
3443
- return new Map;
3444
- }
3445
- }
3446
- function saveLastSeenMessageTs(m) {
3447
- try {
3448
- writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
3449
- } catch {}
3578
+ return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
3450
3579
  }
3451
3580
  var lastSeenMessageTs = loadLastSeenMessageTs();
3452
3581
  var cursorFlushIntervalMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
3453
- var lastSeenMessageTsDirty = false;
3582
+ var cursorState = { dirty: false };
3454
3583
  var lastSeenMessageTsTimer = null;
3455
3584
  function flushLastSeenMessageTs() {
3456
- if (!lastSeenMessageTsDirty)
3585
+ if (!cursorState.dirty)
3457
3586
  return;
3458
- lastSeenMessageTsDirty = false;
3459
3587
  if (lastSeenMessageTsTimer) {
3460
3588
  clearTimeout(lastSeenMessageTsTimer);
3461
3589
  lastSeenMessageTsTimer = null;
3462
3590
  }
3463
- saveLastSeenMessageTs(lastSeenMessageTs);
3591
+ flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
3464
3592
  }
3465
3593
  function scheduleLastSeenMessageTsSave() {
3466
- lastSeenMessageTsDirty = true;
3594
+ cursorState.dirty = true;
3467
3595
  if (lastSeenMessageTsTimer)
3468
3596
  return;
3469
3597
  lastSeenMessageTsTimer = setTimeout(() => {
@@ -3943,7 +4071,10 @@ function shutdownFromStdio(reason) {
3943
4071
  `);
3944
4072
  try {
3945
4073
  flushLastSeenMessageTs();
3946
- } catch {}
4074
+ } catch (e) {
4075
+ safeStderrWrite(`[agentchat] WARNING: read-cursor flush failed on shutdown: ${e}
4076
+ `);
4077
+ }
3947
4078
  try {
3948
4079
  heartbeat.stop();
3949
4080
  } catch {}
@@ -3987,7 +4118,10 @@ function installStdioLifecycleGuards() {
3987
4118
  process.on("beforeExit", () => {
3988
4119
  try {
3989
4120
  flushLastSeenMessageTs();
3990
- } catch {}
4121
+ } catch (e) {
4122
+ safeStderrWrite(`[agentchat] WARNING: read-cursor fallback flush failed: ${e}
4123
+ `);
4124
+ }
3991
4125
  });
3992
4126
  }
3993
4127
  async function checkVersionStaleness() {
@@ -4008,7 +4142,12 @@ async function checkVersionStaleness() {
4008
4142
  }
4009
4143
  async function main() {
4010
4144
  installStdioLifecycleGuards();
4011
- connectWS();
4145
+ if (anonymousMode) {
4146
+ process.stderr.write(`[agentchat] Anonymous \u2014 not connecting to the hub.
4147
+ `);
4148
+ } else {
4149
+ connectWS();
4150
+ }
4012
4151
  transport = new StdioServerTransport;
4013
4152
  await server.connect(transport);
4014
4153
  process.stderr.write(`[agentchat] MCP server started (Stdio)
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.29.1",
4
+ "version": "0.30.0",
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": {
@@ -9,6 +9,7 @@
9
9
  "agentchat-mcp": "src/cli.mjs"
10
10
  },
11
11
  "engines": {
12
+ "node": ">=22",
12
13
  "bun": ">=1.0.0"
13
14
  },
14
15
  "scripts": {
@@ -63,6 +64,9 @@
63
64
  "src/reconnect.ts",
64
65
  "src/timestamps.ts",
65
66
  "src/argcheck.ts",
67
+ "src/identity.ts",
68
+ "src/profile-store.ts",
69
+ "src/read-cursor.ts",
66
70
  "dist/server.js",
67
71
  "README.md"
68
72
  ]
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Startup identity policy — pure decision logic, no I/O.
3
+ *
4
+ * Lives outside the side-effecting server entrypoint (which registers accounts and
5
+ * writes credential files on import) so it can be unit-tested.
6
+ *
7
+ * Why this exists: auto-registration creates a REAL account on the server, and
8
+ * accounts cannot be deleted. It must never fire implicitly. Previously a host that
9
+ * simply forgot to declare an identity (no --name/--profile/AGENTSCHAT_PROFILE, no
10
+ * token) would mint an anonymous `Claude-xxxxxx` agent on every first start — and
11
+ * because the bare fallback path is SHARED (`~/.agentschat/profile.json`), every
12
+ * later identity-less session loads that same file and collapses onto that one
13
+ * agent. A second trigger did the same for any profile still carrying `dev-token`.
14
+ *
15
+ * Policy: registration requires explicit opt-in (`--name` or `--register`).
16
+ * - Nothing declared at all → ANONYMOUS: no registration, no profile written.
17
+ * stdio still answers initialize/tools/list, so registry introspection (Glama
18
+ * builds and runs the server with zero config) keeps working; anything needing
19
+ * auth fails loudly at call time rather than silently creating an account.
20
+ * - An identity WAS declared but its profile is missing → hard error. That is a
21
+ * typo/misconfig, and inventing an identity for it is what corrupted attribution
22
+ * before. Introspection never hits this branch (it passes no flags).
23
+ */
24
+
25
+ /** Which resolution tier produced the profile path (see resolveProfile in server.ts). */
26
+ export type ProfileSource = "env" | "legacy-env" | "flag-profile" | "flag-name" | "default";
27
+
28
+ export type IdentityDecision =
29
+ /** Profile file exists — load it. */
30
+ | { mode: "profile" }
31
+ /** Explicit token supplied via --token/AGENTCHAT_TOKEN — authenticate, never register. */
32
+ | { mode: "env-creds" }
33
+ /** Explicit opt-in (--name / --register) — register a new account and persist it. */
34
+ | { mode: "register"; displayName: string }
35
+ /** Identity declared but profile missing — refuse to invent one. */
36
+ | { mode: "error"; message: string }
37
+ /** Nothing declared — run unauthenticated, register nothing, persist nothing. */
38
+ | { mode: "anonymous"; reason: string };
39
+
40
+ export interface IdentityInputs {
41
+ /** Does the resolved profile file already exist on disk? */
42
+ profileExists: boolean;
43
+ /** Which tier resolved the path. "default" means nothing was declared. */
44
+ source: ProfileSource;
45
+ /** The resolved profile path (used in operator-facing messages). */
46
+ profileFile: string;
47
+ /** Value of --name, if given. */
48
+ cliName?: string;
49
+ /** Value of --profile, or the *_PROFILE env var — used only for the error message. */
50
+ declaredName?: string;
51
+ /** Explicit --register opt-in. */
52
+ registerFlag?: boolean;
53
+ /** A token was supplied out-of-band (--token / AGENTCHAT_TOKEN). */
54
+ hasToken: boolean;
55
+ /** Generated name to use when --register is passed without --name. */
56
+ fallbackName: string;
57
+ }
58
+
59
+ export function decideIdentity(i: IdentityInputs): IdentityDecision {
60
+ // An existing profile is authoritative — this is the overwhelmingly common path.
61
+ if (i.profileExists) return { mode: "profile" };
62
+
63
+ // Credentials handed to us directly: we can authenticate, so there is nothing to
64
+ // register. (Previously this still registered, because the branch keyed only on
65
+ // the profile file being absent.)
66
+ if (i.hasToken) return { mode: "env-creds" };
67
+
68
+ // Explicit opt-in to creating a new account.
69
+ if (i.cliName) return { mode: "register", displayName: i.cliName };
70
+ if (i.registerFlag) return { mode: "register", displayName: i.fallbackName };
71
+
72
+ // An identity was named but no profile backs it. Do NOT invent one.
73
+ if (i.source !== "default") {
74
+ const name = i.declaredName ?? i.cliName ?? "(unknown)";
75
+ return {
76
+ mode: "error",
77
+ message:
78
+ `no profile for "${name}" at ${i.profileFile}.\n` +
79
+ ` Refusing to auto-register — that creates a real account, and accounts cannot be deleted.\n` +
80
+ ` Use an existing profile: --profile <name> (or AGENTSCHAT_PROFILE=<name>)\n` +
81
+ ` Register a NEW agent: --name <new-name> (or --register)\n` +
82
+ ` Authenticate directly: AGENTCHAT_TOKEN=<token>`,
83
+ };
84
+ }
85
+
86
+ // Nothing declared. Stay usable for introspection, but create nothing.
87
+ return {
88
+ mode: "anonymous",
89
+ reason:
90
+ `no agent identity configured — running ANONYMOUS (tools are listed; any call needing auth will fail).\n` +
91
+ ` Refusing to auto-register: it would create a real, undeletable account and persist its\n` +
92
+ ` credentials to the shared default profile (${i.profileFile}), which every later\n` +
93
+ ` identity-less session would then load as its own.\n` +
94
+ ` To fix: --name <your-agent> register a new agent\n` +
95
+ ` --profile <name> use an existing profile (or AGENTSCHAT_PROFILE=<name>)\n` +
96
+ ` AGENTCHAT_TOKEN=<t> authenticate directly`,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Second auto-register trigger: a profile that loaded successfully but still carries
102
+ * the placeholder `dev-token`. Legacy behavior re-registered it to heal the key —
103
+ * fine for an explicitly declared identity, but on the bare shared default path it
104
+ * mints an anonymous account exactly like the first trigger. Same opt-in gate.
105
+ */
106
+ export function shouldMigrateDevToken(i: {
107
+ source: ProfileSource;
108
+ hasToken: boolean;
109
+ registerFlag?: boolean;
110
+ }): boolean {
111
+ if (i.hasToken) return false; // out-of-band creds win; nothing to heal
112
+ if (i.registerFlag) return true; // explicit opt-in
113
+ return i.source !== "default"; // an identity was declared → healing it is intended
114
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Atomic, permission-safe write of the agent profile.
3
+ *
4
+ * Extracted from the side-effecting server entrypoint so it can be unit-tested: the
5
+ * property that matters here (the key is never world-readable, even mid-write) is only
6
+ * observable if you can call this directly and interrupt it.
7
+ *
8
+ * The profile holds a live agent key, so 0600 is a security control. Two traps:
9
+ * - `writeFileSync`'s `mode` applies ONLY when the file is created. A .tmp left behind by
10
+ * an earlier crash keeps its own permissions, and `renameSync` preserves the source's
11
+ * mode — so a stale 0644 .tmp yields a 0644 profile. Unlinking it first makes 0600 true
12
+ * *by construction*: the key is never on disk world-readable, not even in the window
13
+ * between rename and chmod.
14
+ * - `chmodSync` used to be the sole enforcement point, and its failure was swallowed by a
15
+ * bare `catch {}` — the key could sit world-readable with nothing said. Never swallow
16
+ * it, and verify the result instead of assuming it took.
17
+ */
18
+ import { existsSync, writeFileSync, renameSync, chmodSync, unlinkSync, statSync } from "fs";
19
+
20
+ /** Where diagnostics go. Injectable so tests can assert on them. */
21
+ export type Warn = (message: string) => void;
22
+
23
+ const defaultWarn: Warn = (m) => process.stderr.write(m);
24
+
25
+ export function safeWriteProfile(path: string, data: unknown, warn: Warn = defaultWarn): void {
26
+ const tmp = path + ".tmp";
27
+
28
+ // Residue from an earlier crash would keep its own (possibly 0644) permissions.
29
+ try {
30
+ if (existsSync(tmp)) unlinkSync(tmp);
31
+ } catch (e) {
32
+ warn(`[agentchat] WARNING: stale ${tmp} could not be removed: ${e}\n`);
33
+ }
34
+
35
+ // Created fresh → `mode` actually applies, so the key is 0600 from the instant it exists.
36
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
37
+ renameSync(tmp, path);
38
+
39
+ try {
40
+ chmodSync(path, 0o600);
41
+ } catch (e) {
42
+ warn(`[agentchat] WARNING: could not chmod ${path} to 0600: ${e}\n`);
43
+ }
44
+
45
+ try {
46
+ const mode = statSync(path).mode & 0o777;
47
+ if (mode !== 0o600) {
48
+ warn(
49
+ `[agentchat] WARNING: ${path} is mode ${mode.toString(8)}, expected 600 — it holds your agent key. Fix: chmod 600 ${path}\n`,
50
+ );
51
+ }
52
+ } catch (e) {
53
+ warn(`[agentchat] WARNING: could not verify permissions of ${path}: ${e}\n`);
54
+ }
55
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Persistence of the per-agent read cursor (`last-seen-msg-ts-<agent>.json`).
3
+ *
4
+ * This is a state change, not best-effort teardown — it just happens to be *called*
5
+ * from teardown. Losing it silently means the next start reads a stale last-seen value
6
+ * and either replays messages or, worse, SKIPS them (the cursor-gap class).
7
+ *
8
+ * Two defects this module exists to prevent:
9
+ * - The old writer swallowed its write error in a bare `catch {}` and returned normally,
10
+ * so callers could not tell a failed flush from a successful one.
11
+ * - The old flush cleared the dirty flag BEFORE writing. A failed write therefore both
12
+ * discarded the cursor and disabled its own retry: the shutdown fallback flush sees
13
+ * `dirty === false` and returns immediately. One failure = permanent silent loss.
14
+ *
15
+ * So: the write reports success, and the dirty flag is cleared ONLY once it lands.
16
+ */
17
+ import { readFileSync, writeFileSync } from "fs";
18
+
19
+ export type Warn = (message: string) => void;
20
+
21
+ /**
22
+ * Read a persisted channel→timestamp map.
23
+ *
24
+ * A missing file is the normal first-run case and stays quiet. Anything else — corrupt
25
+ * JSON, bad permissions — means we are silently resetting state we were supposed to
26
+ * remember, and this runs ONCE at startup: there is no second attempt to notice it.
27
+ */
28
+ export function loadCursor(file: string, warn: Warn): Map<string, string> {
29
+ try {
30
+ return new Map(Object.entries(JSON.parse(readFileSync(file, "utf-8")) as Record<string, string>));
31
+ } catch (e) {
32
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") {
33
+ warn(`[agentchat] WARNING: could not read ${file} — resetting that state: ${e}\n`);
34
+ }
35
+ return new Map();
36
+ }
37
+ }
38
+
39
+ /** Write the cursor to disk. Returns true iff it actually landed. Never throws. */
40
+ export function persistCursor(file: string, cursor: Map<string, string>, warn: Warn): boolean {
41
+ try {
42
+ writeFileSync(file, JSON.stringify(Object.fromEntries(cursor)));
43
+ return true;
44
+ } catch (e) {
45
+ warn(`[agentchat] WARNING: failed to persist read cursor to ${file}: ${e}\n`);
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Flush state machine. Clears `dirty` only after `persist()` reports success, so a
52
+ * failed write stays dirty and the next flush (including the shutdown fallback) retries.
53
+ * Returns true iff the cursor was persisted.
54
+ */
55
+ export function flushCursor(state: { dirty: boolean }, persist: () => boolean): boolean {
56
+ if (!state.dirty) return false;
57
+ const ok = persist();
58
+ if (ok) state.dirty = false;
59
+ return ok;
60
+ }
package/src/server.ts CHANGED
@@ -40,6 +40,8 @@ import { messageDedupKey, MessageDedup } from "./dedup.ts";
40
40
  import { computeReconnectDelay } from "./reconnect.ts";
41
41
  import { normalizeTimestampForCursor } from "./timestamps.ts";
42
42
  import { validateToolArgs } from "./argcheck.ts";
43
+ import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
44
+ import type { ProfileSource } from "./identity.ts";
43
45
  import pkg from "../package.json";
44
46
  import {
45
47
  CallToolRequestSchema,
@@ -47,16 +49,13 @@ import {
47
49
  } from "@modelcontextprotocol/sdk/types.js";
48
50
 
49
51
  // --- Config: CLI args > env vars > profile file > defaults ---
50
- import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync, readdirSync } from "fs";
52
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
51
53
  import { join, dirname } from "path";
52
-
53
- /** Atomic write: write to .tmp then rename (prevents corrupted profile on crash) */
54
- function safeWriteProfile(path: string, data: any) {
55
- const tmp = path + ".tmp";
56
- writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
57
- renameSync(tmp, path);
58
- try { chmodSync(path, 0o600); } catch {}
59
- }
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";
60
59
  import { randomUUID } from "crypto";
61
60
 
62
61
  function parseArgs() {
@@ -69,6 +68,8 @@ function parseArgs() {
69
68
  else if (args[i] === "--token" && args[i + 1]) parsed.token = args[++i];
70
69
  else if (args[i] === "--caps" && args[i + 1]) parsed.caps = args[++i];
71
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";
72
73
  }
73
74
  return parsed;
74
75
  }
@@ -80,14 +81,19 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
80
81
  claude --dangerously-load-development-channels server:agentschat
81
82
 
82
83
  Options:
83
- --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.
84
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)
85
88
  --id <id> Agent ID (default: auto-generated)
86
89
  --url <url> Server URL (default: production)
87
- --token <token> Auth token (default: auto-registered)
90
+ --token <token> Auth token (skips registration entirely)
88
91
  --caps <a,b,c> Capabilities (comma-separated)
89
92
  -h, --help Show this help
90
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
+
91
97
  Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
92
98
  Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
93
99
  process.exit(0);
@@ -133,20 +139,30 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
133
139
  return profiles;
134
140
  }
135
141
 
136
- function resolveProfilePath(): string {
142
+ function resolveProfile(): { path: string; source: ProfileSource; declaredName?: string } {
137
143
  // 1. AGENTSCHAT_PROFILE env var (supports both name and full path)
138
- 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 };
139
146
  // 2. AGENTCHAT_PROFILE env var (legacy alias)
140
- 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 };
141
149
  // 3. --profile <name>
142
- if (cliArgs.profile) return nameToPath(cliArgs.profile);
150
+ if (cliArgs.profile) return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
143
151
  // 4. --name <name>
144
- if (cliArgs.name) return nameToPath(cliArgs.name);
145
- // 5. default
146
- 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" };
147
155
  }
148
156
 
149
- 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;
150
166
  let profile: any = {};
151
167
 
152
168
  const DEFAULT_SERVER = "https://agents-chat.com";
@@ -157,14 +173,82 @@ const WS_URL = process.env.AGENTCHAT_URL || (() => {
157
173
  })();
158
174
  const REST_URL = serverUrl;
159
175
 
160
- 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") {
161
230
  profile = JSON.parse(readFileSync(profileFile, "utf-8"));
162
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`);
163
247
  } else {
164
- // First run: auto-register with server to get a real agent key
165
- 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;
166
250
  const caps = ["claude-code", "coding", "chat"];
167
- process.stderr.write(`[agentchat] First run — registering with server...\n`);
251
+ process.stderr.write(`[agentchat] Registering "${displayName}" with server...\n`);
168
252
  try {
169
253
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
170
254
  method: "POST",
@@ -188,7 +272,11 @@ if (existsSync(profileFile)) {
188
272
  profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
189
273
  }
190
274
  } catch (e) {
191
- 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`);
192
280
  profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
193
281
  }
194
282
  mkdirSync(dirname(profileFile), { recursive: true });
@@ -196,8 +284,15 @@ if (existsSync(profileFile)) {
196
284
  process.stderr.write(`[agentchat] Profile saved: ${profileFile}\n`);
197
285
  }
198
286
 
199
- // Migrate old profiles with dev-token: auto-register to get real key
200
- 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") {
201
296
  process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
202
297
  try {
203
298
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
@@ -226,38 +321,17 @@ if (profile.token === "dev-token") {
226
321
  process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
227
322
  }
228
323
  }
229
- } catch {}
230
- }
231
-
232
- let AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
233
- let TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
234
- let CAPABILITIES: string[] = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
235
-
236
- // Native fetch, captured before the file-wide call-site rename to apiFetch so the
237
- // wrapper below can't recurse into itself.
238
- const nativeFetch = fetch;
239
- // All AgentsChat REST goes through apiFetch so every call gets, from one place:
240
- // (1) a timeout — a hung hub call must never block a tool forever; and
241
- // (2) the bearer token, injected only when absent and TOKEN is set (so the few
242
- // conditional-auth sites keep their exact semantics). init is otherwise passed
243
- // through untouched, so callers keep using r.ok / r.text() / r.json().
244
- const REST_TIMEOUT_MS = 15_000;
245
- async function apiFetch(
246
- input: string | URL,
247
- init: RequestInit = {},
248
- timeoutMs = REST_TIMEOUT_MS,
249
- ): Promise<Response> {
250
- const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
251
- if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
252
- const controller = new AbortController();
253
- const timer = setTimeout(() => controller.abort(), timeoutMs);
254
- try {
255
- return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
256
- } finally {
257
- 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`);
258
327
  }
259
328
  }
260
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
+
261
335
  // Update display name if provided via CLI
262
336
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
263
337
  profile.display_name = cliArgs.name;
@@ -2714,7 +2788,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2714
2788
  } catch (e: any) {
2715
2789
  authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
2716
2790
  }
2717
- 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)")}` }] };
2718
2792
  }
2719
2793
 
2720
2794
  if (name === "list_channels") {
@@ -2907,11 +2981,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2907
2981
  }
2908
2982
  sessionId = null;
2909
2983
 
2910
- // 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.
2911
2986
  AGENT_ID = newProfile.agent_id;
2912
2987
  TOKEN = newProfile.token || "dev-token";
2913
2988
  CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
2914
2989
  profile = newProfile;
2990
+ activeProfileFile = targetFile;
2991
+ anonymousMode = false;
2915
2992
 
2916
2993
  // Restart heartbeat and connect with new identity
2917
2994
  wsReconnectAttempt = 0;
@@ -3286,15 +3363,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3286
3363
  // Persisted to disk so reconnect/restart doesn't lose state
3287
3364
  const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
3288
3365
  function loadMentionTimestamps(): Map<string, string> {
3289
- try {
3290
- const raw = readFileSync(mentionTsFile, "utf-8");
3291
- return new Map(Object.entries(JSON.parse(raw)));
3292
- } catch { return new Map(); }
3366
+ return loadCursor(mentionTsFile, safeStderrWrite);
3293
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.
3294
3370
  function saveMentionTimestamps(m: Map<string, string>) {
3295
- try {
3296
- writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
3297
- } catch {}
3371
+ persistCursor(mentionTsFile, m, safeStderrWrite);
3298
3372
  }
3299
3373
  const lastMentionTimestamp = loadMentionTimestamps();
3300
3374
 
@@ -3315,36 +3389,29 @@ const lastMentionTimestamp = loadMentionTimestamps();
3315
3389
  // connect or reconnect; if we missed anything, we find it.
3316
3390
  const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3317
3391
  function loadLastSeenMessageTs(): Map<string, string> {
3318
- try {
3319
- const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
3320
- return new Map(Object.entries(JSON.parse(raw)));
3321
- } catch { return new Map(); }
3322
- }
3323
- function saveLastSeenMessageTs(m: Map<string, string>) {
3324
- try {
3325
- writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
3326
- } catch {}
3392
+ return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
3327
3393
  }
3328
3394
  const lastSeenMessageTs = loadLastSeenMessageTs();
3329
3395
  const cursorFlushIntervalMs = Math.max(
3330
3396
  500,
3331
3397
  Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
3332
3398
  );
3333
- 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 };
3334
3401
  let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
3335
3402
 
3336
3403
  function flushLastSeenMessageTs() {
3337
- if (!lastSeenMessageTsDirty) return;
3338
- lastSeenMessageTsDirty = false;
3404
+ if (!cursorState.dirty) return;
3339
3405
  if (lastSeenMessageTsTimer) {
3340
3406
  clearTimeout(lastSeenMessageTsTimer);
3341
3407
  lastSeenMessageTsTimer = null;
3342
3408
  }
3343
- saveLastSeenMessageTs(lastSeenMessageTs);
3409
+ // Stays dirty if the write fails, so the shutdown fallback flush actually retries.
3410
+ flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
3344
3411
  }
3345
3412
 
3346
3413
  function scheduleLastSeenMessageTsSave() {
3347
- lastSeenMessageTsDirty = true;
3414
+ cursorState.dirty = true;
3348
3415
  if (lastSeenMessageTsTimer) return;
3349
3416
  lastSeenMessageTsTimer = setTimeout(() => {
3350
3417
  lastSeenMessageTsTimer = null;
@@ -3934,7 +4001,8 @@ function shutdownFromStdio(reason: string) {
3934
4001
  if (shuttingDown) return;
3935
4002
  shuttingDown = true;
3936
4003
  safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3937
- 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`); }
3938
4006
  try { heartbeat.stop(); } catch {}
3939
4007
  try { stopAllTypingHeartbeats(); } catch {}
3940
4008
  if (reconnectTimer) {
@@ -3971,7 +4039,7 @@ function installStdioLifecycleGuards() {
3971
4039
  process.stderr.on("error", handleOutputError);
3972
4040
  process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
3973
4041
  process.on("beforeExit", () => {
3974
- try { flushLastSeenMessageTs(); } catch {}
4042
+ try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor fallback flush failed: ${e}\n`); }
3975
4043
  });
3976
4044
  }
3977
4045
 
@@ -4004,7 +4072,13 @@ async function checkVersionStaleness(): Promise<void> {
4004
4072
  // --- Start ---
4005
4073
  async function main() {
4006
4074
  installStdioLifecycleGuards();
4007
- 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
+ }
4008
4082
 
4009
4083
  // Stdio is the only supported transport. The --port HTTP SSE path was
4010
4084
  // removed in v0.6.7 — OpenClaw users should install the native channel