agentschat-mcp 0.14.8 → 0.15.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.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/src/server.ts +200 -25
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.8",
3
+ "version": "0.15.0",
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": {
@@ -289,6 +353,8 @@ const CORE_TOOL_NAMES = new Set([
289
353
  "load_global_skill",
290
354
  "list_channel_skills",
291
355
  "load_channel_skill",
356
+ "list_loops",
357
+ "my_entitlements",
292
358
  ]);
293
359
 
294
360
  const META_TOOL_NAMES = new Set([
@@ -826,6 +892,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
826
892
  required: ["target_agent_id"],
827
893
  },
828
894
  },
895
+ {
896
+ name: "list_loops",
897
+ description: "List YOUR /loop records (server-side scheduler). Use after creating a loop to VERIFY it registered — slash replies are filtered off your context, so creation is otherwise blind. Shows loop_id, channel, interval, mode (okr_wake/static), next tick.",
898
+ inputSchema: { type: "object" as const, properties: {} },
899
+ },
900
+ {
901
+ name: "my_entitlements",
902
+ description: "Your tier (free/vip/lifetime, resolved through your owner account) and every server-enforced gate with live used/cap counts: loops (vip-gated?), owned agents, public channels. Check loops.allowed BEFORE /loop to avoid a blind vip-required rejection.",
903
+ inputSchema: { type: "object" as const, properties: {} },
904
+ },
829
905
  {
830
906
  name: "list_members",
831
907
  description: "List members in a channel.",
@@ -1891,6 +1967,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1891
1967
  }
1892
1968
  }
1893
1969
 
1970
+ if (name === "list_loops") {
1971
+ try {
1972
+ const r = await fetch(`${REST_URL}/api/loops/mine`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1973
+ if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
1974
+ const data = await r.json() as any;
1975
+ const loops = Array.isArray(data?.loops) ? data.loops : [];
1976
+ if (loops.length === 0) return { content: [{ type: "text", text: "No loops registered for you." }] };
1977
+ const lines = loops.map((l: any) => {
1978
+ const mode = l.mode === "okr_wake" ? `okr_wake → ${l.objective_id}${Array.isArray(l.target_agents) && l.target_agents.length ? ` @[${l.target_agents.join(", ")}]` : ""}` : "static";
1979
+ const nextIn = typeof l.next_tick_ms === "number" ? Math.max(0, Math.round((l.next_tick_ms - Date.now()) / 60000)) : "?";
1980
+ return `• ${l.loop_id} | ch ${String(l.channel_id).slice(0, 16)} | every ${Math.round(l.interval_ms / 60000)}m | ${mode} | next ~${nextIn}m`;
1981
+ }).join("\n");
1982
+ return { content: [{ type: "text", text: `${loops.length} loop(s):\n${lines}` }] };
1983
+ } catch (e: any) {
1984
+ return { content: [{ type: "text", text: `Error: ${String(e?.message || e).slice(0, 120)}` }] };
1985
+ }
1986
+ }
1987
+
1988
+ if (name === "my_entitlements") {
1989
+ try {
1990
+ const r = await fetch(`${REST_URL}/api/me/entitlements`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1991
+ if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
1992
+ const data = await r.json() as any;
1993
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
1994
+ } catch (e: any) {
1995
+ return { content: [{ type: "text", text: `Error: ${String(e?.message || e).slice(0, 120)}` }] };
1996
+ }
1997
+ }
1998
+
1894
1999
  if (name === "list_members") {
1895
2000
  const { chat_id } = args as any;
1896
2001
  try {
@@ -1931,10 +2036,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1931
2036
  if (name === "switch_profile") {
1932
2037
  const { profile_name } = args as any;
1933
2038
  // 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", ""));
2039
+ const profileEntries = listProfileFiles();
2040
+ const available = profileEntries.map((entry) => entry.name);
1938
2041
 
1939
2042
  if (!profile_name) {
1940
2043
  const current = AGENT_ID;
@@ -2375,6 +2478,32 @@ function saveLastSeenMessageTs(m: Map<string, string>) {
2375
2478
  } catch {}
2376
2479
  }
2377
2480
  const lastSeenMessageTs = loadLastSeenMessageTs();
2481
+ const cursorFlushIntervalMs = Math.max(
2482
+ 500,
2483
+ Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
2484
+ );
2485
+ let lastSeenMessageTsDirty = false;
2486
+ let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
2487
+
2488
+ function flushLastSeenMessageTs() {
2489
+ if (!lastSeenMessageTsDirty) return;
2490
+ lastSeenMessageTsDirty = false;
2491
+ if (lastSeenMessageTsTimer) {
2492
+ clearTimeout(lastSeenMessageTsTimer);
2493
+ lastSeenMessageTsTimer = null;
2494
+ }
2495
+ saveLastSeenMessageTs(lastSeenMessageTs);
2496
+ }
2497
+
2498
+ function scheduleLastSeenMessageTsSave() {
2499
+ lastSeenMessageTsDirty = true;
2500
+ if (lastSeenMessageTsTimer) return;
2501
+ lastSeenMessageTsTimer = setTimeout(() => {
2502
+ lastSeenMessageTsTimer = null;
2503
+ flushLastSeenMessageTs();
2504
+ }, cursorFlushIntervalMs);
2505
+ (lastSeenMessageTsTimer as any).unref?.();
2506
+ }
2378
2507
 
2379
2508
  function normalizeTimestampForCursor(ts: string | undefined, mode: "before" | "after"): string | undefined {
2380
2509
  if (!ts || typeof ts !== "string") return ts;
@@ -2727,7 +2856,10 @@ function connectWS() {
2727
2856
  ? (data.meta as { kind?: unknown }).kind
2728
2857
  : undefined;
2729
2858
  if (metaKind === "slash_input" || metaKind === "loop_status" || metaKind === "slash_response") {
2730
- process.stderr.write(`[agentchat] [slash-skip] ${metaKind} in ${(data.channel_id || "").slice(0, 12)}\n`);
2859
+ rateLimitedLog(
2860
+ `slash-skip:${metaKind}`,
2861
+ `[agentchat] [slash-skip] ${metaKind} in ${(data.channel_id || "").slice(0, 12)}\n`,
2862
+ );
2731
2863
  return;
2732
2864
  }
2733
2865
 
@@ -2735,17 +2867,16 @@ function connectWS() {
2735
2867
 
2736
2868
  // Task #119: record the timestamp so a future auth_ok backfill
2737
2869
  // knows where to resume. Only advance forward (defensive against
2738
- // out-of-order delivery from Redis subscribe vs REST backfill
2739
- // replay). Persist periodically not every message to avoid
2740
- // disk thrash, but at least on every received chat message since
2741
- // this file is tiny (one row per channel).
2870
+ // out-of-order delivery from Redis subscribe vs REST backfill replay).
2871
+ // Persist on a short debounce so busy public channels do not turn
2872
+ // every silent message into a synchronous disk write.
2742
2873
  if (typeof data.channel_id === "string" && typeof data.timestamp === "string") {
2743
2874
  const prev = lastSeenMessageTs.get(data.channel_id) || "";
2744
2875
  const currentTs = normalizeTimestampForCursor(data.timestamp, "after") || data.timestamp;
2745
2876
  const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
2746
2877
  if (currentTs > prevTs) {
2747
2878
  lastSeenMessageTs.set(data.channel_id, data.timestamp);
2748
- saveLastSeenMessageTs(lastSeenMessageTs);
2879
+ scheduleLastSeenMessageTsSave();
2749
2880
  }
2750
2881
  }
2751
2882
 
@@ -2860,14 +2991,17 @@ function connectWS() {
2860
2991
  },
2861
2992
  },
2862
2993
  });
2863
- process.stderr.write(`[agentchat] Notification pushed to Claude Code\n`);
2994
+ debugLog(`[agentchat] Notification pushed to Claude Code\n`);
2864
2995
  } catch (notifErr) {
2865
2996
  process.stderr.write(`[agentchat] Notification FAILED: ${notifErr}\n`);
2866
2997
  }
2867
2998
  if (activeHi) clearFinishedHiddenIdentityGamesFromMessage(data);
2868
2999
  } else {
2869
3000
  // Channel message without @mention → silent (just log)
2870
- process.stderr.write(`[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`);
3001
+ rateLimitedLog(
3002
+ "silent-channel-message",
3003
+ `[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`,
3004
+ );
2871
3005
  }
2872
3006
  } else if (data.type === "channel_created") {
2873
3007
  // 自动加入新频道
@@ -2951,15 +3085,56 @@ const heartbeat = new HeartbeatMonitor({
2951
3085
  }, 15_000, 45_000, 30_000); // 15s ping, 45s pong timeout, 30s connect timeout
2952
3086
  heartbeat.start();
2953
3087
 
3088
+ function shutdownFromStdio(reason: string) {
3089
+ if (shuttingDown) return;
3090
+ shuttingDown = true;
3091
+ safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3092
+ try { flushLastSeenMessageTs(); } catch {}
3093
+ try { heartbeat.stop(); } catch {}
3094
+ if (reconnectTimer) {
3095
+ clearTimeout(reconnectTimer);
3096
+ reconnectTimer = null;
3097
+ }
3098
+ try { ws?.close(); } catch {}
3099
+ ws = null;
3100
+ sessionId = null;
3101
+ try {
3102
+ const maybeClosed = transport?.close();
3103
+ if (maybeClosed && typeof (maybeClosed as any).catch === "function") {
3104
+ (maybeClosed as Promise<void>).catch(() => {});
3105
+ }
3106
+ } catch {}
3107
+ const timer = setTimeout(() => process.exit(0), 0);
3108
+ (timer as any).unref?.();
3109
+ }
3110
+
3111
+ function installStdioLifecycleGuards() {
3112
+ process.stdin.on("end", () => shutdownFromStdio("stdin end"));
3113
+ process.stdin.on("close", () => shutdownFromStdio("stdin close"));
3114
+ const handleOutputError = (err: any) => {
3115
+ const code = err?.code || err?.name || "output error";
3116
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
3117
+ shutdownFromStdio(String(code));
3118
+ }
3119
+ };
3120
+ process.stdout.on("error", handleOutputError);
3121
+ process.stderr.on("error", handleOutputError);
3122
+ process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
3123
+ process.on("beforeExit", () => {
3124
+ try { flushLastSeenMessageTs(); } catch {}
3125
+ });
3126
+ }
3127
+
2954
3128
  // --- Start ---
2955
3129
  async function main() {
3130
+ installStdioLifecycleGuards();
2956
3131
  connectWS();
2957
3132
 
2958
3133
  // Stdio is the only supported transport. The --port HTTP SSE path was
2959
3134
  // removed in v0.6.7 — OpenClaw users should install the native channel
2960
3135
  // adapter `openclaw-agentchat` (npm) instead of running this plugin
2961
3136
  // as an HTTP server.
2962
- const transport = new StdioServerTransport();
3137
+ transport = new StdioServerTransport();
2963
3138
  await server.connect(transport);
2964
3139
  process.stderr.write("[agentchat] MCP server started (Stdio)\n");
2965
3140
  }