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 +3 -3
- package/dist/server.js +221 -82
- package/package.json +5 -1
- package/src/identity.ts +114 -0
- package/src/profile-store.ts +55 -0
- package/src/read-cursor.ts +60 -0
- package/src/server.ts +155 -81
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
### 1. Install
|
|
8
8
|
|
|
9
|
-
> **
|
|
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 --
|
|
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.
|
|
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
|
|
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 (
|
|
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) =>
|
|
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
|
|
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 =
|
|
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
|
-
|
|
396
|
-
|
|
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 =
|
|
552
|
+
const displayName = identity.displayName;
|
|
401
553
|
const caps = ["claude-code", "coding", "chat"];
|
|
402
|
-
process.stderr.write(`[agentchat]
|
|
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]
|
|
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
|
-
|
|
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 (!
|
|
1895
|
+
if (!existsSync2(path))
|
|
1752
1896
|
throw new Error(`file not found: ${path}`);
|
|
1753
1897
|
const mime = mimeFromPath(path);
|
|
1754
|
-
const buf =
|
|
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(
|
|
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
|
-
|
|
2170
|
-
|
|
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(
|
|
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
|
-
|
|
2205
|
-
|
|
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: ${
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3582
|
+
var cursorState = { dirty: false };
|
|
3454
3583
|
var lastSeenMessageTsTimer = null;
|
|
3455
3584
|
function flushLastSeenMessageTs() {
|
|
3456
|
-
if (!
|
|
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
|
-
|
|
3591
|
+
flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
|
|
3464
3592
|
}
|
|
3465
3593
|
function scheduleLastSeenMessageTsSave() {
|
|
3466
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
]
|
package/src/identity.ts
ADDED
|
@@ -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,
|
|
52
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
51
53
|
import { join, dirname } from "path";
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 (
|
|
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
|
|
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)
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
165
|
-
const displayName =
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
200
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
3338
|
-
lastSeenMessageTsDirty = false;
|
|
3404
|
+
if (!cursorState.dirty) return;
|
|
3339
3405
|
if (lastSeenMessageTsTimer) {
|
|
3340
3406
|
clearTimeout(lastSeenMessageTsTimer);
|
|
3341
3407
|
lastSeenMessageTsTimer = null;
|
|
3342
3408
|
}
|
|
3343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|