agentschat-mcp 0.29.1 → 0.31.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
@@ -40,6 +40,9 @@ 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";
45
+ import { decideTermsConsent, TERMS_URL } from "./terms.ts";
43
46
  import pkg from "../package.json";
44
47
  import {
45
48
  CallToolRequestSchema,
@@ -47,16 +50,13 @@ import {
47
50
  } from "@modelcontextprotocol/sdk/types.js";
48
51
 
49
52
  // --- Config: CLI args > env vars > profile file > defaults ---
50
- import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync, readdirSync } from "fs";
53
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
51
54
  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
- }
55
+ // Atomic + 0600-by-construction profile writer. Lives in its own module so the
56
+ // "the key is never world-readable, even mid-write" property is directly testable.
57
+ import { safeWriteProfile } from "./profile-store.ts";
58
+ // Read-cursor persistence: a state change that merely *lives* in the teardown path.
59
+ import { flushCursor, loadCursor, persistCursor } from "./read-cursor.ts";
60
60
  import { randomUUID } from "crypto";
61
61
 
62
62
  function parseArgs() {
@@ -69,6 +69,10 @@ function parseArgs() {
69
69
  else if (args[i] === "--token" && args[i + 1]) parsed.token = args[++i];
70
70
  else if (args[i] === "--caps" && args[i + 1]) parsed.caps = args[++i];
71
71
  else if (args[i] === "--profile" && args[i + 1]) parsed.profile = args[++i];
72
+ // Boolean flag: explicit opt-in to creating a NEW account (see src/identity.ts).
73
+ else if (args[i] === "--register") parsed.register = "1";
74
+ // Boolean flag: explicit acceptance of the terms registration requires (src/terms.ts).
75
+ else if (args[i] === "--accept-terms") parsed.acceptTerms = "1";
72
76
  }
73
77
  return parsed;
74
78
  }
@@ -80,14 +84,21 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
80
84
  claude --dangerously-load-development-channels server:agentschat
81
85
 
82
86
  Options:
83
- --name <name> Display name (also used as profile name)
87
+ --name <name> Display name (also used as profile name). Registers a NEW agent
88
+ if no profile exists for it.
84
89
  --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
90
+ --register Explicitly opt in to registering a new agent (implied by --name)
91
+ --accept-terms Accept the terms at https://agents-chat.com/terms. REQUIRED to
92
+ register (or AGENTSCHAT_ACCEPT_TERMS=1); never assumed for you.
85
93
  --id <id> Agent ID (default: auto-generated)
86
94
  --url <url> Server URL (default: production)
87
- --token <token> Auth token (default: auto-registered)
95
+ --token <token> Auth token (skips registration entirely)
88
96
  --caps <a,b,c> Capabilities (comma-separated)
89
97
  -h, --help Show this help
90
98
 
99
+ Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
100
+ no token, the server runs ANONYMOUS (lists tools, but never registers an account).
101
+
91
102
  Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
92
103
  Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
93
104
  process.exit(0);
@@ -133,20 +144,30 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
133
144
  return profiles;
134
145
  }
135
146
 
136
- function resolveProfilePath(): string {
147
+ function resolveProfile(): { path: string; source: ProfileSource; declaredName?: string } {
137
148
  // 1. AGENTSCHAT_PROFILE env var (supports both name and full path)
138
- if (process.env.AGENTSCHAT_PROFILE) return nameToPath(process.env.AGENTSCHAT_PROFILE);
149
+ if (process.env.AGENTSCHAT_PROFILE)
150
+ return { path: nameToPath(process.env.AGENTSCHAT_PROFILE), source: "env", declaredName: process.env.AGENTSCHAT_PROFILE };
139
151
  // 2. AGENTCHAT_PROFILE env var (legacy alias)
140
- if (process.env.AGENTCHAT_PROFILE) return nameToPath(process.env.AGENTCHAT_PROFILE);
152
+ if (process.env.AGENTCHAT_PROFILE)
153
+ return { path: nameToPath(process.env.AGENTCHAT_PROFILE), source: "legacy-env", declaredName: process.env.AGENTCHAT_PROFILE };
141
154
  // 3. --profile <name>
142
- if (cliArgs.profile) return nameToPath(cliArgs.profile);
155
+ if (cliArgs.profile) return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
143
156
  // 4. --name <name>
144
- if (cliArgs.name) return nameToPath(cliArgs.name);
145
- // 5. default
146
- return nameToPath("profile");
157
+ if (cliArgs.name) return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
158
+ // 5. default — nothing was declared. NOT a licence to invent an identity.
159
+ return { path: nameToPath("profile"), source: "default" };
147
160
  }
148
161
 
149
- const profileFile = resolveProfilePath();
162
+ const { path: profileFile, source: profileSource, declaredName } = resolveProfile();
163
+ /**
164
+ * The profile file currently in effect. Mutable because `switch_profile` swaps
165
+ * identity at runtime — `whoami` must report the live one, not the boot-time one.
166
+ * null = no profile is backing this session (anonymous, or token-only).
167
+ */
168
+ let activeProfileFile: string | null = profileFile;
169
+ /** No identity at all: serve tools/list, register nothing, connect nothing. */
170
+ let anonymousMode = false;
150
171
  let profile: any = {};
151
172
 
152
173
  const DEFAULT_SERVER = "https://agents-chat.com";
@@ -157,19 +178,107 @@ const WS_URL = process.env.AGENTCHAT_URL || (() => {
157
178
  })();
158
179
  const REST_URL = serverUrl;
159
180
 
160
- if (existsSync(profileFile)) {
181
+ // Identity, declared (not just hoisted) BEFORE anything can call apiFetch. These are
182
+ // filled in from `profile` once the identity block below has run. Registration runs
183
+ // before that and must not read them through the temporal dead zone — see below.
184
+ let AGENT_ID = "";
185
+ let TOKEN = "";
186
+ let CAPABILITIES: string[] = [];
187
+
188
+ // Native fetch, captured before the file-wide call-site rename to apiFetch so the
189
+ // wrapper below can't recurse into itself.
190
+ const nativeFetch = fetch;
191
+ // All AgentsChat REST goes through apiFetch so every call gets, from one place:
192
+ // (1) a timeout — a hung hub call must never block a tool forever; and
193
+ // (2) the bearer token, injected only when absent and TOKEN is set (so the few
194
+ // conditional-auth sites keep their exact semantics). init is otherwise passed
195
+ // through untouched, so callers keep using r.ok / r.text() / r.json().
196
+ //
197
+ // This must be defined ABOVE the registration block. apiFetch is a hoisted function,
198
+ // but its `timeoutMs = REST_TIMEOUT_MS` default and its `TOKEN` read are evaluated at
199
+ // CALL time: when the bare-fetch→apiFetch refactor moved these call sites above the
200
+ // const declarations, every /api/account/register call threw a TDZ ReferenceError that
201
+ // the surrounding `catch` reported as "Server unreachable" — silently disabling
202
+ // registration (and its dev-token migration twin, whose catch is empty). Registration
203
+ // happens before TOKEN is assigned, so TOKEN is "" there and no Authorization header is
204
+ // sent — exactly what the register endpoint expects.
205
+ const REST_TIMEOUT_MS = 15_000;
206
+ async function apiFetch(
207
+ input: string | URL,
208
+ init: RequestInit = {},
209
+ timeoutMs = REST_TIMEOUT_MS,
210
+ ): Promise<Response> {
211
+ const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
212
+ if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
213
+ const controller = new AbortController();
214
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
215
+ try {
216
+ return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
217
+ } finally {
218
+ clearTimeout(timer);
219
+ }
220
+ }
221
+
222
+ const hasToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
223
+ const identity = decideIdentity({
224
+ profileExists: existsSync(profileFile),
225
+ source: profileSource,
226
+ profileFile,
227
+ cliName: cliArgs.name,
228
+ declaredName,
229
+ registerFlag: !!cliArgs.register,
230
+ hasToken,
231
+ fallbackName: `Claude-${randomUUID().slice(0, 6)}`,
232
+ });
233
+
234
+ if (identity.mode === "profile") {
161
235
  profile = JSON.parse(readFileSync(profileFile, "utf-8"));
162
236
  process.stderr.write(`[agentchat] Profile loaded: ${profileFile}\n`);
237
+ } else if (identity.mode === "env-creds") {
238
+ // Token handed to us directly — authenticate with it, register nothing, write nothing.
239
+ activeProfileFile = null;
240
+ process.stderr.write(`[agentchat] Using credentials from environment — not registering.\n`);
241
+ } else if (identity.mode === "error") {
242
+ // A declared identity with no profile behind it. Inventing one is what corrupted
243
+ // attribution before, so fail loudly instead. Introspection never lands here.
244
+ process.stderr.write(`[agentchat] ERROR: ${identity.message}\n`);
245
+ process.exit(1);
246
+ } else if (identity.mode === "anonymous") {
247
+ // Nothing declared. Stay alive so stdio introspection (initialize/tools/list) works,
248
+ // but create no account and persist no credentials.
249
+ anonymousMode = true;
250
+ activeProfileFile = null;
251
+ process.stderr.write(`[agentchat] ${identity.reason}\n`);
163
252
  } else {
164
- // First run: auto-register with server to get a real agent key
165
- const displayName = cliArgs.name || `Claude-${randomUUID().slice(0, 6)}`;
253
+ // Explicit opt-in: register a real account and persist it.
254
+ const displayName = identity.displayName;
166
255
  const caps = ["claude-code", "coding", "chat"];
167
- process.stderr.write(`[agentchat] First run — registering with server...\n`);
256
+
257
+ // The hub requires acceptance of its terms to register an agent. We will not send
258
+ // that acceptance unless the operator gave it — silently agreeing on their behalf
259
+ // to a document they were never shown is not ours to do.
260
+ const consent = decideTermsConsent({
261
+ acceptFlag: !!cliArgs.acceptTerms,
262
+ acceptEnv: process.env.AGENTSCHAT_ACCEPT_TERMS,
263
+ });
264
+ if (consent.mode === "refused") {
265
+ process.stderr.write(`[agentchat] ERROR: ${consent.message}\n`);
266
+ process.exit(1);
267
+ }
268
+
269
+ process.stderr.write(`[agentchat] Registering "${displayName}" with server...\n`);
168
270
  try {
169
271
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
170
272
  method: "POST",
171
273
  headers: { "Content-Type": "application/json" },
172
- body: JSON.stringify({ name: displayName, type: "agent", capabilities: caps, source: "mcp" }),
274
+ body: JSON.stringify({
275
+ name: displayName,
276
+ type: "agent",
277
+ capabilities: caps,
278
+ source: "mcp",
279
+ accepted_terms: true,
280
+ terms_version: consent.version,
281
+ }),
173
282
  });
174
283
  if (regRes.ok) {
175
284
  const data = await regRes.json() as any;
@@ -183,27 +292,64 @@ if (existsSync(profileFile)) {
183
292
  if (data.claim_url) process.stderr.write(`[agentchat] Share this with your owner: ${data.claim_url}\n`);
184
293
  process.stderr.write(`[agentchat] Next steps: say hi in the welcome channel (reply tool) · try \`/loop 30m <prompt>\` in a DM (14-day trial) · call my_entitlements to see your powers\n`);
185
294
  } else {
186
- // Registration failed — fall back to local profile
187
- process.stderr.write(`[agentchat] Registration failed (${regRes.status}), using local profile\n`);
188
- profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
295
+ // A failed registration must NOT produce a runnable-looking agent. The old
296
+ // fallback wrote a `dev-token` profile and started anyway: the client showed a
297
+ // connected server with a full tool list while every authenticated call 401'd,
298
+ // and the placeholder profile then loaded as authoritative on every later run,
299
+ // making the dead identity permanent. Surface the hub's own words and stop.
300
+ const body = await regRes.text().catch(() => "");
301
+ process.stderr.write(
302
+ `[agentchat] ERROR: registration refused by ${REST_URL} — HTTP ${regRes.status} ${body.slice(0, 300)}\n` +
303
+ ` No profile was written and no account exists. Nothing is running.\n` +
304
+ ` If this mentions terms, read ${TERMS_URL} and re-run with --accept-terms.\n`,
305
+ );
306
+ process.exit(1);
189
307
  }
190
308
  } catch (e) {
191
- process.stderr.write(`[agentchat] Server unreachable, using local profile\n`);
192
- profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
309
+ // Always print the cause. This catch used to report EVERY failure as "Server
310
+ // unreachable" — including the TDZ ReferenceError above, which silently disabled
311
+ // registration for a week while pointing operators at their network. A failure path
312
+ // that fabricates a plausible diagnosis is worse than one that says nothing.
313
+ process.stderr.write(
314
+ `[agentchat] ERROR: Registration failed: ${e}\n` +
315
+ ` No profile was written and no account exists. Nothing is running.\n`,
316
+ );
317
+ process.exit(1);
193
318
  }
194
319
  mkdirSync(dirname(profileFile), { recursive: true });
195
320
  safeWriteProfile(profileFile, profile);
196
321
  process.stderr.write(`[agentchat] Profile saved: ${profileFile}\n`);
197
322
  }
198
323
 
199
- // Migrate old profiles with dev-token: auto-register to get real key
200
- if (profile.token === "dev-token") {
324
+ // Second auto-register trigger: a loaded profile still carrying the `dev-token`
325
+ // placeholder. Healing it is intended for a declared identity, but on the bare
326
+ // shared default path it mints an anonymous account just like the first trigger.
327
+ if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSource, hasToken, registerFlag: !!cliArgs.register })) {
328
+ process.stderr.write(
329
+ `[agentchat] Profile at ${profileFile} carries a dev-token but no identity was declared — ` +
330
+ `refusing to auto-register. Pass --name <name> or --register to create a real agent.\n`,
331
+ );
332
+ } else if (profile.token === "dev-token") {
333
+ // Healing a dev-token profile registers a real account too, so it needs the same
334
+ // consent. Without this gate it just 400s on `accepted_terms` and leaves the
335
+ // placeholder in place — the dead-agent state this whole path exists to escape.
336
+ const migrationConsent = decideTermsConsent({
337
+ acceptFlag: !!cliArgs.acceptTerms,
338
+ acceptEnv: process.env.AGENTSCHAT_ACCEPT_TERMS,
339
+ });
340
+ if (migrationConsent.mode === "refused") {
341
+ process.stderr.write(
342
+ `[agentchat] Profile at ${profileFile} carries a placeholder dev-token and cannot authenticate.\n` +
343
+ ` Healing it registers a real account: ${migrationConsent.message}\n`,
344
+ );
345
+ } else {
346
+ const terms = { accepted_terms: true, terms_version: migrationConsent.version };
201
347
  process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
202
348
  try {
203
349
  const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
204
350
  method: "POST",
205
351
  headers: { "Content-Type": "application/json" },
206
- body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
352
+ body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [], ...terms }),
207
353
  });
208
354
  if (regRes.ok) {
209
355
  const data = await regRes.json() as any;
@@ -216,7 +362,7 @@ if (profile.token === "dev-token") {
216
362
  const regRes2 = await apiFetch(`${REST_URL}/api/account/register`, {
217
363
  method: "POST",
218
364
  headers: { "Content-Type": "application/json" },
219
- body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
365
+ body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [], ...terms }),
220
366
  });
221
367
  if (regRes2.ok) {
222
368
  const data = await regRes2.json() as any;
@@ -224,40 +370,47 @@ if (profile.token === "dev-token") {
224
370
  profile.token = data.key;
225
371
  safeWriteProfile(profileFile, profile);
226
372
  process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
373
+ } else {
374
+ // Both attempts refused. Say so — the profile still cannot authenticate.
375
+ const body = await regRes2.text().catch(() => "");
376
+ process.stderr.write(
377
+ `[agentchat] WARNING: dev-token migration refused — HTTP ${regRes2.status} ${body.slice(0, 200)}\n` +
378
+ ` This profile still holds a placeholder token; authenticated calls will fail.\n`,
379
+ );
227
380
  }
228
381
  }
229
- } catch {}
382
+ } catch (e) {
383
+ // Was `catch {}`: it swallowed the same TDZ ReferenceError with no output at all.
384
+ process.stderr.write(`[agentchat] dev-token migration failed: ${e}\n`);
385
+ }
386
+ }
230
387
  }
231
388
 
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);
258
- }
389
+ // Single exit for every way a placeholder token survives the block above (refused
390
+ // consent, refused heal, no identity declared). `dev-token` cannot authenticate, so
391
+ // booting on it yields a server that lists its full toolset while every call 401s —
392
+ // the same dead-agent-in-live-clothes this release removed from the registration
393
+ // path, just reached down a different branch. Enforced once here rather than per
394
+ // branch so a future fourth path cannot reintroduce it.
395
+ //
396
+ // Anonymous mode is deliberately NOT caught: it loads no profile (profile.token is
397
+ // undefined), so zero-config registry introspection keeps working.
398
+ if (profile.token === "dev-token") {
399
+ process.stderr.write(
400
+ `[agentchat] ERROR: profile ${profileFile} holds a placeholder dev-token, which cannot authenticate.\n` +
401
+ ` Not starting — a server that lists tools it cannot use is worse than one that fails.\n` +
402
+ ` Heal it: --accept-terms (registers a real account for this profile)\n` +
403
+ ` Or replace: register at https://agents-chat.com/join, then use --profile <name>\n` +
404
+ ` or AGENTCHAT_TOKEN=<token>\n`,
405
+ );
406
+ process.exit(1);
259
407
  }
260
408
 
409
+ // Now that the identity block has settled `profile`, bind the runtime identity.
410
+ AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
411
+ TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
412
+ CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
413
+
261
414
  // Update display name if provided via CLI
262
415
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
263
416
  profile.display_name = cliArgs.name;
@@ -2714,7 +2867,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2714
2867
  } catch (e: any) {
2715
2868
  authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
2716
2869
  }
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}` }] };
2870
+ 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
2871
  }
2719
2872
 
2720
2873
  if (name === "list_channels") {
@@ -2907,11 +3060,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2907
3060
  }
2908
3061
  sessionId = null;
2909
3062
 
2910
- // Update identity
3063
+ // Update identity. activeProfileFile must move too — whoami reports it, and a
3064
+ // stale value there makes the identity probe lie about which profile is live.
2911
3065
  AGENT_ID = newProfile.agent_id;
2912
3066
  TOKEN = newProfile.token || "dev-token";
2913
3067
  CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
2914
3068
  profile = newProfile;
3069
+ activeProfileFile = targetFile;
3070
+ anonymousMode = false;
2915
3071
 
2916
3072
  // Restart heartbeat and connect with new identity
2917
3073
  wsReconnectAttempt = 0;
@@ -3286,15 +3442,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3286
3442
  // Persisted to disk so reconnect/restart doesn't lose state
3287
3443
  const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
3288
3444
  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(); }
3445
+ return loadCursor(mentionTsFile, safeStderrWrite);
3293
3446
  }
3447
+ // Re-attempted on the next mention, but a persistent failure (perms, full disk) would
3448
+ // retry just as silently forever — and the state is gone on restart. So it reports.
3294
3449
  function saveMentionTimestamps(m: Map<string, string>) {
3295
- try {
3296
- writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
3297
- } catch {}
3450
+ persistCursor(mentionTsFile, m, safeStderrWrite);
3298
3451
  }
3299
3452
  const lastMentionTimestamp = loadMentionTimestamps();
3300
3453
 
@@ -3315,36 +3468,29 @@ const lastMentionTimestamp = loadMentionTimestamps();
3315
3468
  // connect or reconnect; if we missed anything, we find it.
3316
3469
  const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
3317
3470
  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 {}
3471
+ return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
3327
3472
  }
3328
3473
  const lastSeenMessageTs = loadLastSeenMessageTs();
3329
3474
  const cursorFlushIntervalMs = Math.max(
3330
3475
  500,
3331
3476
  Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
3332
3477
  );
3333
- let lastSeenMessageTsDirty = false;
3478
+ /** Dirty flag lives in an object so flushCursor can clear it only on a landed write. */
3479
+ const cursorState = { dirty: false };
3334
3480
  let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
3335
3481
 
3336
3482
  function flushLastSeenMessageTs() {
3337
- if (!lastSeenMessageTsDirty) return;
3338
- lastSeenMessageTsDirty = false;
3483
+ if (!cursorState.dirty) return;
3339
3484
  if (lastSeenMessageTsTimer) {
3340
3485
  clearTimeout(lastSeenMessageTsTimer);
3341
3486
  lastSeenMessageTsTimer = null;
3342
3487
  }
3343
- saveLastSeenMessageTs(lastSeenMessageTs);
3488
+ // Stays dirty if the write fails, so the shutdown fallback flush actually retries.
3489
+ flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
3344
3490
  }
3345
3491
 
3346
3492
  function scheduleLastSeenMessageTsSave() {
3347
- lastSeenMessageTsDirty = true;
3493
+ cursorState.dirty = true;
3348
3494
  if (lastSeenMessageTsTimer) return;
3349
3495
  lastSeenMessageTsTimer = setTimeout(() => {
3350
3496
  lastSeenMessageTsTimer = null;
@@ -3934,7 +4080,8 @@ function shutdownFromStdio(reason: string) {
3934
4080
  if (shuttingDown) return;
3935
4081
  shuttingDown = true;
3936
4082
  safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3937
- try { flushLastSeenMessageTs(); } catch {}
4083
+ // Persisting the read cursor is a state change, not teardown: report, never swallow.
4084
+ try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor flush failed on shutdown: ${e}\n`); }
3938
4085
  try { heartbeat.stop(); } catch {}
3939
4086
  try { stopAllTypingHeartbeats(); } catch {}
3940
4087
  if (reconnectTimer) {
@@ -3971,7 +4118,7 @@ function installStdioLifecycleGuards() {
3971
4118
  process.stderr.on("error", handleOutputError);
3972
4119
  process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
3973
4120
  process.on("beforeExit", () => {
3974
- try { flushLastSeenMessageTs(); } catch {}
4121
+ try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor fallback flush failed: ${e}\n`); }
3975
4122
  });
3976
4123
  }
3977
4124
 
@@ -4004,7 +4151,13 @@ async function checkVersionStaleness(): Promise<void> {
4004
4151
  // --- Start ---
4005
4152
  async function main() {
4006
4153
  installStdioLifecycleGuards();
4007
- connectWS();
4154
+ // Anonymous = no credentials to present. Connecting would just fail auth in a
4155
+ // reconnect loop; stdio (initialize/tools/list) is served either way.
4156
+ if (anonymousMode) {
4157
+ process.stderr.write(`[agentchat] Anonymous — not connecting to the hub.\n`);
4158
+ } else {
4159
+ connectWS();
4160
+ }
4008
4161
 
4009
4162
  // Stdio is the only supported transport. The --port HTTP SSE path was
4010
4163
  // removed in v0.6.7 — OpenClaw users should install the native channel
package/src/terms.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Terms-of-service consent for agent registration — pure decision logic, no I/O.
3
+ *
4
+ * Why this exists: the hub began requiring `accepted_terms` on
5
+ * /api/account/register. 0.30.0 did not send it, so every self-serve
6
+ * `npx agentschat-mcp --name X` got:
7
+ *
8
+ * 400 {"error":"accepted_terms required for agent registration"}
9
+ *
10
+ * ...and the register-failure path then wrote a `dev-token` profile and started the
11
+ * server anyway. The result read as success in the client (tools listed, whoami
12
+ * answers) while every authenticated call 401'd — a dead agent wearing a live one's
13
+ * clothes, with the only truth in stderr nobody reads.
14
+ *
15
+ * The obvious patch — hardcode `accepted_terms: true` — is the wrong fix. It records
16
+ * agreement to a legal document on behalf of an operator who was never shown it. So
17
+ * consent is explicit (`--accept-terms` / AGENTSCHAT_ACCEPT_TERMS) and its absence is
18
+ * a loud refusal that prints the document URL, not a silent degraded start.
19
+ *
20
+ * Lives outside the side-effecting entrypoint so it can be unit-tested (same reason
21
+ * as identity.ts).
22
+ */
23
+
24
+ /** The published agreement an agent registration accepts. Verified live: 200. */
25
+ export const TERMS_URL = "https://agents-chat.com/terms";
26
+
27
+ /**
28
+ * Version string sent alongside the acceptance. The hub stamps its own
29
+ * `termsVersion` on the account; this records which text the operator was pointed at.
30
+ * If the hub ever rejects it, the register call surfaces the server's error verbatim
31
+ * rather than guessing — see the register call site in server.ts.
32
+ */
33
+ export const TERMS_VERSION = "2026-05-29";
34
+
35
+ export interface TermsInputs {
36
+ /** `--accept-terms` was passed. */
37
+ acceptFlag?: boolean;
38
+ /** Raw value of AGENTSCHAT_ACCEPT_TERMS (for non-interactive hosts). */
39
+ acceptEnv?: string;
40
+ }
41
+
42
+ export type TermsDecision =
43
+ /** Operator accepted — registration may proceed and carry these fields. */
44
+ | { mode: "accepted"; version: string }
45
+ /** No explicit acceptance — refuse to register, and say what to do about it. */
46
+ | { mode: "refused"; message: string };
47
+
48
+ /** Only these spellings count as consent; anything else (incl. "0"/"false") does not. */
49
+ function isTruthy(v: string | undefined): boolean {
50
+ return typeof v === "string" && /^(1|true|yes)$/i.test(v.trim());
51
+ }
52
+
53
+ export function decideTermsConsent(i: TermsInputs): TermsDecision {
54
+ if (i.acceptFlag || isTruthy(i.acceptEnv)) {
55
+ return { mode: "accepted", version: TERMS_VERSION };
56
+ }
57
+ return {
58
+ mode: "refused",
59
+ message:
60
+ `registering an agent requires accepting the AgentsChat terms (version ${TERMS_VERSION}).\n` +
61
+ ` Read them: ${TERMS_URL}\n` +
62
+ ` Then re-run with: --accept-terms (or AGENTSCHAT_ACCEPT_TERMS=1)\n` +
63
+ ` Prefer a browser? Register at https://agents-chat.com/join and pass the\n` +
64
+ ` resulting credentials via --profile <name> or AGENTCHAT_TOKEN=<token>.\n` +
65
+ ` Refusing to send acceptance you did not give — no account was created.`,
66
+ };
67
+ }