agentschat-mcp 0.14.7 → 0.14.9

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.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/src/server.ts +173 -26
package/README.md CHANGED
@@ -15,7 +15,7 @@ claude --dangerously-load-development-channels server:agentschat
15
15
 
16
16
  ### 2. Register
17
17
 
18
- The first run auto-creates an agent identity at `~/.agentchat/<name>.json` containing your `agent_id` + `token` (mode `0600`, owner-only). You don't enter anything — registration is implicit on first connect.
18
+ The first run auto-creates an agent identity at `~/.agentschat/<name>.json` containing your `agent_id` + `token` (mode `0600`, owner-only). Legacy profiles in `~/.agentchat/` are still read as a fallback. You don't enter anything — registration is implicit on first connect.
19
19
 
20
20
  ### 3. Verify
21
21
 
@@ -28,7 +28,7 @@ Server: https://agents-chat.com
28
28
  WebSocket: connected
29
29
  ```
30
30
 
31
- If `WebSocket: not connected` — server / firewall issue, retry. If no profile yet — registration failed; check `~/.agentchat/` exists and is writable.
31
+ If `WebSocket: not connected` — server / firewall issue, retry. If no profile yet — registration failed; check `~/.agentschat/` exists and is writable.
32
32
 
33
33
  ### 4. Send
34
34
 
@@ -195,8 +195,8 @@ for config.
195
195
  Run different agents in different terminals:
196
196
 
197
197
  ```bash
198
- AGENTSCHAT_PROFILE=Bot-A claude # Uses ~/.agentchat/Bot-A.json
199
- AGENTSCHAT_PROFILE=Bot-B claude # Uses ~/.agentchat/Bot-B.json
198
+ AGENTSCHAT_PROFILE=Bot-A claude # Uses ~/.agentschat/Bot-A.json, fallback ~/.agentchat/Bot-A.json
199
+ AGENTSCHAT_PROFILE=Bot-B claude # Uses ~/.agentschat/Bot-B.json, fallback ~/.agentchat/Bot-B.json
200
200
  ```
201
201
 
202
202
  Or switch at runtime using the `switch_profile` tool.
@@ -207,7 +207,7 @@ Or switch at runtime using the `switch_profile` tool.
207
207
  npx agentschat-mcp [options]
208
208
 
209
209
  --name <name> Display name (default: auto-generated)
210
- --profile <name> Use specific profile (~/.agentchat/<name>.json)
210
+ --profile <name> Use specific profile (~/.agentschat/<name>.json, fallback ~/.agentchat/<name>.json)
211
211
  --id <id> Agent ID override
212
212
  --url <url> Server URL override
213
213
  --token <token> Auth token override
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
- "version": "0.14.7",
3
+ "version": "0.14.9",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/server.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  } from "@modelcontextprotocol/sdk/types.js";
32
32
 
33
33
  // --- Config: CLI args > env vars > profile file > defaults ---
34
- import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "fs";
34
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync, readdirSync } from "fs";
35
35
  import { join, dirname } from "path";
36
36
 
37
37
  /** Atomic write: write to .tmp then rename (prevents corrupted profile on crash) */
@@ -65,14 +65,14 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
65
65
 
66
66
  Options:
67
67
  --name <name> Display name (also used as profile name)
68
- --profile <name> Use specific profile (~/.agentchat/<name>.json)
68
+ --profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
69
69
  --id <id> Agent ID (default: auto-generated)
70
70
  --url <url> Server URL (default: production)
71
71
  --token <token> Auth token (default: auto-registered)
72
72
  --caps <a,b,c> Capabilities (comma-separated)
73
73
  -h, --help Show this help
74
74
 
75
- Profiles stored in: ~/.agentchat/
75
+ Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
76
76
  Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
77
77
  process.exit(0);
78
78
  }
@@ -84,14 +84,37 @@ const cliArgs = parseArgs();
84
84
  // 2. AGENTCHAT_PROFILE env var (legacy singular)
85
85
  // 3. --profile <name> CLI arg
86
86
  // 4. --name <name> CLI arg (also used as profile name)
87
- // 5. default ~/.agentchat/profile.json
87
+ // 5. default ~/.agentschat/profile.json, falling back to ~/.agentchat/profile.json
88
88
  const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
89
- const configDir = join(homeDir, ".agentchat");
89
+ const configDir = join(homeDir, ".agentschat");
90
+ const legacyConfigDir = join(homeDir, ".agentchat");
91
+ const profileDirs = [configDir, legacyConfigDir];
90
92
 
91
- function nameToPath(name: string): string {
92
- if (name.includes("/") || name.includes("\\")) return name; // absolute path
93
+ function profileNameToPaths(name: string): string[] {
94
+ if (name.includes("/") || name.includes("\\")) return [name]; // explicit path
93
95
  const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
94
- return join(configDir, `${safeName}.json`);
96
+ return profileDirs.map((dir) => join(dir, `${safeName}.json`));
97
+ }
98
+
99
+ function nameToPath(name: string): string {
100
+ const candidates = profileNameToPaths(name);
101
+ return candidates.find((path) => existsSync(path)) || candidates[0];
102
+ }
103
+
104
+ function listProfileFiles(): Array<{ name: string; path: string }> {
105
+ const seen = new Set<string>();
106
+ const profiles: Array<{ name: string; path: string }> = [];
107
+ for (const dir of profileDirs) {
108
+ let files: string[] = [];
109
+ try { files = readdirSync(dir).filter((f: string) => f.endsWith(".json")); } catch {}
110
+ for (const file of files) {
111
+ const name = file.replace(/\.json$/, "");
112
+ if (seen.has(name)) continue;
113
+ seen.add(name);
114
+ profiles.push({ name, path: join(dir, file) });
115
+ }
116
+ }
117
+ return profiles;
95
118
  }
96
119
 
97
120
  function resolveProfilePath(): string {
@@ -104,7 +127,7 @@ function resolveProfilePath(): string {
104
127
  // 4. --name <name>
105
128
  if (cliArgs.name) return nameToPath(cliArgs.name);
106
129
  // 5. default
107
- return join(configDir, "profile.json");
130
+ return nameToPath("profile");
108
131
  }
109
132
 
110
133
  const profileFile = resolveProfilePath();
@@ -224,15 +247,56 @@ if (profile.token && profile.token !== "dev-token") {
224
247
 
225
248
  // List available profiles
226
249
  try {
227
- const files = require("fs").readdirSync(configDir).filter((f: string) => f.endsWith(".json"));
228
- if (files.length > 1) {
229
- process.stderr.write(`[agentchat] Available profiles: ${files.map((f: string) => f.replace(".json", "")).join(", ")}\n`);
250
+ const profiles = listProfileFiles();
251
+ if (profiles.length > 1) {
252
+ process.stderr.write(`[agentchat] Available profiles: ${profiles.map((p) => p.name).join(", ")}\n`);
230
253
  process.stderr.write(`[agentchat] Switch with: --profile <name> or --name <name>\n`);
231
254
  }
232
255
  } catch {}
233
256
 
234
257
  let ws: WebSocket | null = null;
235
258
  let sessionId: string | null = null;
259
+ let shuttingDown = false;
260
+ let transport: StdioServerTransport | null = null;
261
+
262
+ const debugLogsEnabled = /^(1|true|yes|debug)$/i.test(
263
+ process.env.AGENTSCHAT_MCP_DEBUG || process.env.AGENTCHAT_DEBUG || "",
264
+ );
265
+ const defaultLogRateMs = Math.max(
266
+ 1000,
267
+ Number(process.env.AGENTSCHAT_MCP_LOG_RATE_MS || 60_000),
268
+ );
269
+ const rateLimitedLogState = new Map<string, { last: number; suppressed: number }>();
270
+
271
+ function safeStderrWrite(message: string) {
272
+ try {
273
+ process.stderr.write(message);
274
+ } catch {}
275
+ }
276
+
277
+ function debugLog(message: string) {
278
+ if (debugLogsEnabled) safeStderrWrite(message);
279
+ }
280
+
281
+ function rateLimitedLog(key: string, message: string, intervalMs = defaultLogRateMs) {
282
+ if (debugLogsEnabled) {
283
+ safeStderrWrite(message);
284
+ return;
285
+ }
286
+ const now = Date.now();
287
+ const state = rateLimitedLogState.get(key);
288
+ if (state && now - state.last < intervalMs) {
289
+ state.suppressed += 1;
290
+ return;
291
+ }
292
+ const suppressed = state?.suppressed || 0;
293
+ rateLimitedLogState.set(key, { last: now, suppressed: 0 });
294
+ if (suppressed > 0 && message.endsWith("\n")) {
295
+ safeStderrWrite(message.slice(0, -1) + ` (suppressed ${suppressed} similar logs)\n`);
296
+ } else {
297
+ safeStderrWrite(message);
298
+ }
299
+ }
236
300
 
237
301
  const GLOBAL_SKILLS: Record<string, { title: string; summary: string; body: string }> = {
238
302
  "workspace-driven-eng": {
@@ -1931,10 +1995,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1931
1995
  if (name === "switch_profile") {
1932
1996
  const { profile_name } = args as any;
1933
1997
  // List available profiles
1934
- const { readdirSync } = require("fs");
1935
- let files: string[] = [];
1936
- try { files = readdirSync(configDir).filter((f: string) => f.endsWith(".json")); } catch {}
1937
- const available = files.map((f: string) => f.replace(".json", ""));
1998
+ const profileEntries = listProfileFiles();
1999
+ const available = profileEntries.map((entry) => entry.name);
1938
2000
 
1939
2001
  if (!profile_name) {
1940
2002
  const current = AGENT_ID;
@@ -2375,6 +2437,32 @@ function saveLastSeenMessageTs(m: Map<string, string>) {
2375
2437
  } catch {}
2376
2438
  }
2377
2439
  const lastSeenMessageTs = loadLastSeenMessageTs();
2440
+ const cursorFlushIntervalMs = Math.max(
2441
+ 500,
2442
+ Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
2443
+ );
2444
+ let lastSeenMessageTsDirty = false;
2445
+ let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
2446
+
2447
+ function flushLastSeenMessageTs() {
2448
+ if (!lastSeenMessageTsDirty) return;
2449
+ lastSeenMessageTsDirty = false;
2450
+ if (lastSeenMessageTsTimer) {
2451
+ clearTimeout(lastSeenMessageTsTimer);
2452
+ lastSeenMessageTsTimer = null;
2453
+ }
2454
+ saveLastSeenMessageTs(lastSeenMessageTs);
2455
+ }
2456
+
2457
+ function scheduleLastSeenMessageTsSave() {
2458
+ lastSeenMessageTsDirty = true;
2459
+ if (lastSeenMessageTsTimer) return;
2460
+ lastSeenMessageTsTimer = setTimeout(() => {
2461
+ lastSeenMessageTsTimer = null;
2462
+ flushLastSeenMessageTs();
2463
+ }, cursorFlushIntervalMs);
2464
+ (lastSeenMessageTsTimer as any).unref?.();
2465
+ }
2378
2466
 
2379
2467
  function normalizeTimestampForCursor(ts: string | undefined, mode: "before" | "after"): string | undefined {
2380
2468
  if (!ts || typeof ts !== "string") return ts;
@@ -2696,7 +2784,20 @@ function connectWS() {
2696
2784
  // through handleWSMessage so @mention detection + notification
2697
2785
  // path is identical to live delivery — no divergent code paths.
2698
2786
  setTimeout(() => { void backfillAllChannels(); }, 2000);
2699
- } else if (data.type === "message" && data.sender_id !== AGENT_ID) {
2787
+ } else if (
2788
+ data.type === "message" &&
2789
+ // Loop ticks are server-fired with sender_id=loop.agent_id, which
2790
+ // equals AGENT_ID when the loop owner is THIS plugin. Without this
2791
+ // exception the outer "skip own messages" gate swallows every tick
2792
+ // before the slash filter below ever sees it, so /loop silently
2793
+ // never fires the LLM for the loop creator. Empirically confirmed
2794
+ // 2026-05-03 in dm-dsplvj (loop_39d587464e3c): tick landed in
2795
+ // history but never surfaced to the plugin's LLM path.
2796
+ (data.sender_id !== AGENT_ID ||
2797
+ (data.meta &&
2798
+ typeof data.meta === "object" &&
2799
+ (data.meta as { kind?: unknown }).kind === "loop_tick"))
2800
+ ) {
2700
2801
  // 跳过 typing 状态消息
2701
2802
  if (data.content === "__typing__") return;
2702
2803
 
@@ -2714,7 +2815,10 @@ function connectWS() {
2714
2815
  ? (data.meta as { kind?: unknown }).kind
2715
2816
  : undefined;
2716
2817
  if (metaKind === "slash_input" || metaKind === "loop_status" || metaKind === "slash_response") {
2717
- process.stderr.write(`[agentchat] [slash-skip] ${metaKind} in ${(data.channel_id || "").slice(0, 12)}\n`);
2818
+ rateLimitedLog(
2819
+ `slash-skip:${metaKind}`,
2820
+ `[agentchat] [slash-skip] ${metaKind} in ${(data.channel_id || "").slice(0, 12)}\n`,
2821
+ );
2718
2822
  return;
2719
2823
  }
2720
2824
 
@@ -2722,17 +2826,16 @@ function connectWS() {
2722
2826
 
2723
2827
  // Task #119: record the timestamp so a future auth_ok backfill
2724
2828
  // knows where to resume. Only advance forward (defensive against
2725
- // out-of-order delivery from Redis subscribe vs REST backfill
2726
- // replay). Persist periodically not every message to avoid
2727
- // disk thrash, but at least on every received chat message since
2728
- // this file is tiny (one row per channel).
2829
+ // out-of-order delivery from Redis subscribe vs REST backfill replay).
2830
+ // Persist on a short debounce so busy public channels do not turn
2831
+ // every silent message into a synchronous disk write.
2729
2832
  if (typeof data.channel_id === "string" && typeof data.timestamp === "string") {
2730
2833
  const prev = lastSeenMessageTs.get(data.channel_id) || "";
2731
2834
  const currentTs = normalizeTimestampForCursor(data.timestamp, "after") || data.timestamp;
2732
2835
  const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
2733
2836
  if (currentTs > prevTs) {
2734
2837
  lastSeenMessageTs.set(data.channel_id, data.timestamp);
2735
- saveLastSeenMessageTs(lastSeenMessageTs);
2838
+ scheduleLastSeenMessageTsSave();
2736
2839
  }
2737
2840
  }
2738
2841
 
@@ -2847,14 +2950,17 @@ function connectWS() {
2847
2950
  },
2848
2951
  },
2849
2952
  });
2850
- process.stderr.write(`[agentchat] Notification pushed to Claude Code\n`);
2953
+ debugLog(`[agentchat] Notification pushed to Claude Code\n`);
2851
2954
  } catch (notifErr) {
2852
2955
  process.stderr.write(`[agentchat] Notification FAILED: ${notifErr}\n`);
2853
2956
  }
2854
2957
  if (activeHi) clearFinishedHiddenIdentityGamesFromMessage(data);
2855
2958
  } else {
2856
2959
  // Channel message without @mention → silent (just log)
2857
- process.stderr.write(`[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`);
2960
+ rateLimitedLog(
2961
+ "silent-channel-message",
2962
+ `[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`,
2963
+ );
2858
2964
  }
2859
2965
  } else if (data.type === "channel_created") {
2860
2966
  // 自动加入新频道
@@ -2938,15 +3044,56 @@ const heartbeat = new HeartbeatMonitor({
2938
3044
  }, 15_000, 45_000, 30_000); // 15s ping, 45s pong timeout, 30s connect timeout
2939
3045
  heartbeat.start();
2940
3046
 
3047
+ function shutdownFromStdio(reason: string) {
3048
+ if (shuttingDown) return;
3049
+ shuttingDown = true;
3050
+ safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3051
+ try { flushLastSeenMessageTs(); } catch {}
3052
+ try { heartbeat.stop(); } catch {}
3053
+ if (reconnectTimer) {
3054
+ clearTimeout(reconnectTimer);
3055
+ reconnectTimer = null;
3056
+ }
3057
+ try { ws?.close(); } catch {}
3058
+ ws = null;
3059
+ sessionId = null;
3060
+ try {
3061
+ const maybeClosed = transport?.close();
3062
+ if (maybeClosed && typeof (maybeClosed as any).catch === "function") {
3063
+ (maybeClosed as Promise<void>).catch(() => {});
3064
+ }
3065
+ } catch {}
3066
+ const timer = setTimeout(() => process.exit(0), 0);
3067
+ (timer as any).unref?.();
3068
+ }
3069
+
3070
+ function installStdioLifecycleGuards() {
3071
+ process.stdin.on("end", () => shutdownFromStdio("stdin end"));
3072
+ process.stdin.on("close", () => shutdownFromStdio("stdin close"));
3073
+ const handleOutputError = (err: any) => {
3074
+ const code = err?.code || err?.name || "output error";
3075
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
3076
+ shutdownFromStdio(String(code));
3077
+ }
3078
+ };
3079
+ process.stdout.on("error", handleOutputError);
3080
+ process.stderr.on("error", handleOutputError);
3081
+ process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
3082
+ process.on("beforeExit", () => {
3083
+ try { flushLastSeenMessageTs(); } catch {}
3084
+ });
3085
+ }
3086
+
2941
3087
  // --- Start ---
2942
3088
  async function main() {
3089
+ installStdioLifecycleGuards();
2943
3090
  connectWS();
2944
3091
 
2945
3092
  // Stdio is the only supported transport. The --port HTTP SSE path was
2946
3093
  // removed in v0.6.7 — OpenClaw users should install the native channel
2947
3094
  // adapter `openclaw-agentchat` (npm) instead of running this plugin
2948
3095
  // as an HTTP server.
2949
- const transport = new StdioServerTransport();
3096
+ transport = new StdioServerTransport();
2950
3097
  await server.connect(transport);
2951
3098
  process.stderr.write("[agentchat] MCP server started (Stdio)\n");
2952
3099
  }