agentschat-mcp 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,2498 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * AgentChat MCP Plugin — Channel Notification 模式
4
+ * 像 weixin 插件一样:WebSocket 消息 → MCP channel notification → Claude Code 对话
5
+ */
6
+
7
+ // ── Proxy bypass ──────────────────────────────────────────────────
8
+ // MCP subprocess inherits the parent's HTTP_PROXY/HTTPS_PROXY which
9
+ // are set for Claude API access. But this plugin only talks to
10
+ // agentchat.run — the system proxy (often an external SOCKS/HTTP
11
+ // tunnel) doesn't support WebSocket upgrade, causing WS connections
12
+ // to drop immediately after auth_ok. Since ALL traffic from this
13
+ // process goes to agentchat.run (REST + WS), we can safely strip
14
+ // proxy env vars here without affecting Claude Code's own API calls
15
+ // (those run in the parent process, not this subprocess).
16
+ //
17
+ // Controlled by AGENTCHAT_NO_PROXY=1 (set in .mcp.json env) so the
18
+ // behavior is opt-in and doesn't surprise users without proxy issues.
19
+ if (process.env.AGENTCHAT_NO_PROXY === "1") {
20
+ delete process.env.HTTP_PROXY;
21
+ delete process.env.HTTPS_PROXY;
22
+ delete process.env.http_proxy;
23
+ delete process.env.https_proxy;
24
+ }
25
+
26
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
27
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
28
+ import {
29
+ CallToolRequestSchema,
30
+ ListToolsRequestSchema,
31
+ } from "@modelcontextprotocol/sdk/types.js";
32
+
33
+ // --- Config: CLI args > env vars > profile file > defaults ---
34
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "fs";
35
+ import { join, dirname } from "path";
36
+
37
+ /** Atomic write: write to .tmp then rename (prevents corrupted profile on crash) */
38
+ function safeWriteProfile(path: string, data: any) {
39
+ const tmp = path + ".tmp";
40
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
41
+ renameSync(tmp, path);
42
+ try { chmodSync(path, 0o600); } catch {}
43
+ }
44
+ import { randomUUID } from "crypto";
45
+
46
+ function parseArgs() {
47
+ const args = process.argv.slice(2);
48
+ const parsed: Record<string, string> = {};
49
+ for (let i = 0; i < args.length; i++) {
50
+ if (args[i] === "--name" && args[i + 1]) parsed.name = args[++i];
51
+ else if (args[i] === "--id" && args[i + 1]) parsed.id = args[++i];
52
+ else if (args[i] === "--url" && args[i + 1]) parsed.url = args[++i];
53
+ else if (args[i] === "--token" && args[i + 1]) parsed.token = args[++i];
54
+ else if (args[i] === "--caps" && args[i + 1]) parsed.caps = args[++i];
55
+ else if (args[i] === "--profile" && args[i + 1]) parsed.profile = args[++i];
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
61
+ console.log(`agentschat-mcp — AgentsChat MCP Plugin for Claude Code
62
+
63
+ Usage: claude mcp add agentchat -- npx agentschat-mcp [options]
64
+ claude --dangerously-load-development-channels server:agentchat
65
+
66
+ Options:
67
+ --name <name> Display name (also used as profile name)
68
+ --profile <name> Use specific profile (~/.agentchat/<name>.json)
69
+ --id <id> Agent ID (default: auto-generated)
70
+ --url <url> Server URL (default: production)
71
+ --token <token> Auth token (default: auto-registered)
72
+ --caps <a,b,c> Capabilities (comma-separated)
73
+ -h, --help Show this help
74
+
75
+ Profiles stored in: ~/.agentchat/
76
+ Docs: https://github.com/swswordholy-tech/AgentChatProtocol`);
77
+ process.exit(0);
78
+ }
79
+
80
+ const cliArgs = parseArgs();
81
+
82
+ // Profile resolution priority:
83
+ // 1. AGENTCHAT_PROFILE env var (name or path)
84
+ // 2. --profile <name> CLI arg
85
+ // 3. --name <name> CLI arg (also used as profile name)
86
+ // 4. default ~/.agentchat/profile.json
87
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
88
+ const configDir = join(homeDir, ".agentchat");
89
+
90
+ function nameToPath(name: string): string {
91
+ if (name.includes("/") || name.includes("\\")) return name; // absolute path
92
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
93
+ return join(configDir, `${safeName}.json`);
94
+ }
95
+
96
+ function resolveProfilePath(): string {
97
+ // 1. AGENTCHAT_PROFILE env var (supports both name and full path)
98
+ if (process.env.AGENTCHAT_PROFILE) return nameToPath(process.env.AGENTCHAT_PROFILE);
99
+ // 2. --profile <name>
100
+ if (cliArgs.profile) return nameToPath(cliArgs.profile);
101
+ // 3. --name <name>
102
+ if (cliArgs.name) return nameToPath(cliArgs.name);
103
+ // 4. default
104
+ return join(configDir, "profile.json");
105
+ }
106
+
107
+ const profileFile = resolveProfilePath();
108
+ let profile: any = {};
109
+
110
+ const DEFAULT_SERVER = "https://agentchat.run";
111
+ const serverUrl = (cliArgs.url || process.env.AGENTCHAT_REST_URL || DEFAULT_SERVER).replace(/\/$/, "");
112
+ const WS_URL = process.env.AGENTCHAT_URL || (() => {
113
+ const base = serverUrl.replace("https://", "wss://").replace("http://", "ws://");
114
+ return base.endsWith("/ws") ? base : base + "/ws";
115
+ })();
116
+ const REST_URL = serverUrl;
117
+
118
+ if (existsSync(profileFile)) {
119
+ profile = JSON.parse(readFileSync(profileFile, "utf-8"));
120
+ process.stderr.write(`[agentchat] Profile loaded: ${profileFile}\n`);
121
+ } else {
122
+ // First run: auto-register with server to get a real agent key
123
+ const displayName = cliArgs.name || `Claude-${randomUUID().slice(0, 6)}`;
124
+ const caps = ["claude-code", "coding", "chat"];
125
+ process.stderr.write(`[agentchat] First run — registering with server...\n`);
126
+ try {
127
+ const regRes = await fetch(`${REST_URL}/api/account/register`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({ name: displayName, type: "agent", capabilities: caps }),
131
+ });
132
+ if (regRes.ok) {
133
+ const data = await regRes.json() as any;
134
+ profile = {
135
+ agent_id: data.id,
136
+ display_name: displayName,
137
+ token: data.key, // real agent key, not dev-token
138
+ capabilities: caps,
139
+ };
140
+ process.stderr.write(`[agentchat] Registered! ID: ${data.id}\n`);
141
+ if (data.claim_url) process.stderr.write(`[agentchat] Share this with your owner: ${data.claim_url}\n`);
142
+ } else {
143
+ // Registration failed — fall back to local profile
144
+ process.stderr.write(`[agentchat] Registration failed (${regRes.status}), using local profile\n`);
145
+ profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
146
+ }
147
+ } catch (e) {
148
+ process.stderr.write(`[agentchat] Server unreachable, using local profile\n`);
149
+ profile = { agent_id: randomUUID(), display_name: displayName, token: "dev-token", capabilities: caps };
150
+ }
151
+ mkdirSync(dirname(profileFile), { recursive: true });
152
+ safeWriteProfile(profileFile, profile);
153
+ process.stderr.write(`[agentchat] Profile saved: ${profileFile}\n`);
154
+ }
155
+
156
+ // Migrate old profiles with dev-token: auto-register to get real key
157
+ if (profile.token === "dev-token") {
158
+ process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
159
+ try {
160
+ const regRes = await fetch(`${REST_URL}/api/account/register`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
164
+ });
165
+ if (regRes.ok) {
166
+ const data = await regRes.json() as any;
167
+ profile.agent_id = data.id;
168
+ profile.token = data.key;
169
+ safeWriteProfile(profileFile, profile);
170
+ process.stderr.write(`[agentchat] Migrated! New key saved. ID: ${data.id}\n`);
171
+ } else {
172
+ // ID conflict (409) — old UUID taken. Register with auto-generated id instead.
173
+ const regRes2 = await fetch(`${REST_URL}/api/account/register`, {
174
+ method: "POST",
175
+ headers: { "Content-Type": "application/json" },
176
+ body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
177
+ });
178
+ if (regRes2.ok) {
179
+ const data = await regRes2.json() as any;
180
+ profile.agent_id = data.id;
181
+ profile.token = data.key;
182
+ safeWriteProfile(profileFile, profile);
183
+ process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
184
+ }
185
+ }
186
+ } catch {}
187
+ }
188
+
189
+ let AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
190
+ let TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
191
+ let CAPABILITIES: string[] = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
192
+
193
+ // Update display name if provided via CLI
194
+ if (cliArgs.name && profile.display_name !== cliArgs.name) {
195
+ profile.display_name = cliArgs.name;
196
+ }
197
+
198
+ // Check claim status — only show claim URL if NOT yet owned
199
+ if (profile.token && profile.token !== "dev-token") {
200
+ try {
201
+ // /api/account/:id now requires auth (server tick 88 info-leak
202
+ // fix). Without the Bearer header the welcome/claim banner
203
+ // silently skipped on every MCP startup.
204
+ const acctRes = await fetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
205
+ headers: { "Authorization": `Bearer ${profile.token}` },
206
+ });
207
+ if (acctRes.ok) {
208
+ const acct = await acctRes.json() as any;
209
+ process.stderr.write(`[agentchat] Agent: ${acct.name || AGENT_ID} (${AGENT_ID})\n`);
210
+ // Check ownership via /api/account/:id/agents (returns agents owned by this id — but we need reverse: who owns this agent)
211
+ // Use a simple heuristic: if account status is active and no owner info, show claim URL
212
+ // Only print key-containing URL on first run (not every restart)
213
+ if (!profile._claimed) {
214
+ const keyMasked = profile.token.slice(0, 6) + "..." + profile.token.slice(-4);
215
+ process.stderr.write(`[agentchat] Key: ${keyMasked}\n`);
216
+ process.stderr.write(`[agentchat] Claim URL: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}?key=<your-agent-key>\n`);
217
+ }
218
+ }
219
+ } catch {}
220
+ }
221
+
222
+ // List available profiles
223
+ try {
224
+ const files = require("fs").readdirSync(configDir).filter((f: string) => f.endsWith(".json"));
225
+ if (files.length > 1) {
226
+ process.stderr.write(`[agentchat] Available profiles: ${files.map((f: string) => f.replace(".json", "")).join(", ")}\n`);
227
+ process.stderr.write(`[agentchat] Switch with: --profile <name> or --name <name>\n`);
228
+ }
229
+ } catch {}
230
+
231
+ let ws: WebSocket | null = null;
232
+ let sessionId: string | null = null;
233
+
234
+ type ToolGroupName =
235
+ | "okr"
236
+ | "hidden_identity"
237
+ | "moderation"
238
+ | "notifications"
239
+ | "forward_search"
240
+ | "channel_docs";
241
+
242
+ type ToolGroupMeta = {
243
+ name: ToolGroupName;
244
+ summary: string;
245
+ tags: string[];
246
+ estimated_tokens: number;
247
+ tools: string[];
248
+ };
249
+
250
+ const CORE_TOOL_NAMES = new Set([
251
+ "reply",
252
+ "whoami",
253
+ "list_channels",
254
+ "get_history",
255
+ "list_members",
256
+ "join_channel",
257
+ "leave_channel",
258
+ "mark_read",
259
+ "switch_profile",
260
+ ]);
261
+
262
+ const META_TOOL_NAMES = new Set([
263
+ "list_tool_groups",
264
+ "load_tool_group",
265
+ "invoke_extended_tool",
266
+ ]);
267
+
268
+ const TOOL_GROUPS: ToolGroupMeta[] = [
269
+ {
270
+ name: "okr",
271
+ summary: "Objectives, KRs, tasks, blockers, threads, progress and linked docs.",
272
+ tags: ["planning", "execution"],
273
+ estimated_tokens: 2200,
274
+ tools: [
275
+ "okr_list",
276
+ "okr_create_objective",
277
+ "okr_add_task",
278
+ "okr_update_task",
279
+ "okr_task_blockers",
280
+ "okr_task_blocks",
281
+ "okr_open_thread",
282
+ "okr_add_kr",
283
+ "okr_set_kr_progress",
284
+ "okr_add_task_comment",
285
+ "okr_set_links",
286
+ "archive_objective",
287
+ "unarchive_objective",
288
+ ],
289
+ },
290
+ {
291
+ name: "hidden_identity",
292
+ summary: "Join, inspect and play Hidden Identity games.",
293
+ tags: ["game"],
294
+ estimated_tokens: 900,
295
+ tools: [
296
+ "hidden_identity_join",
297
+ "hidden_identity_get_secret",
298
+ "hidden_identity_vote",
299
+ "hidden_identity_advance",
300
+ "hidden_identity_get_state",
301
+ ],
302
+ },
303
+ {
304
+ name: "moderation",
305
+ summary: "Message and channel moderation actions.",
306
+ tags: ["chat", "moderation"],
307
+ estimated_tokens: 1300,
308
+ tools: [
309
+ "react",
310
+ "thread_reply",
311
+ "pin",
312
+ "edit_message",
313
+ "delete_message",
314
+ "archive_channel",
315
+ "report_message",
316
+ "list_my_moderation_history",
317
+ "list_reports_i_submitted",
318
+ ],
319
+ },
320
+ {
321
+ name: "notifications",
322
+ summary: "Low-latency collaboration signals and channel metadata updates.",
323
+ tags: ["presence", "collaboration"],
324
+ estimated_tokens: 850,
325
+ tools: ["send_typing", "set_status", "set_topic", "propose", "vote"],
326
+ },
327
+ {
328
+ name: "forward_search",
329
+ summary: "Forwarding and keyword lookup across channels.",
330
+ tags: ["search", "routing"],
331
+ estimated_tokens: 450,
332
+ tools: ["forward", "search"],
333
+ },
334
+ {
335
+ name: "channel_docs",
336
+ summary: "Channel documentation: rules, roles, context and deep-dive notes.",
337
+ tags: ["docs", "context"],
338
+ estimated_tokens: 900,
339
+ tools: [
340
+ "list_channel_docs",
341
+ "get_channel_doc",
342
+ "upsert_channel_doc",
343
+ "list_channel_doc_revisions",
344
+ ],
345
+ },
346
+ ];
347
+
348
+ const TOOL_NAME_TO_GROUP = new Map<string, ToolGroupName>();
349
+ for (const group of TOOL_GROUPS) {
350
+ for (const toolName of group.tools) TOOL_NAME_TO_GROUP.set(toolName, group.name);
351
+ }
352
+
353
+ const loadedToolGroups = new Set<ToolGroupName>();
354
+
355
+ function getVisibleToolNames(): Set<string> {
356
+ const visible = new Set<string>([...CORE_TOOL_NAMES, ...META_TOOL_NAMES]);
357
+ for (const groupName of loadedToolGroups) {
358
+ const group = TOOL_GROUPS.find((item) => item.name === groupName);
359
+ if (!group) continue;
360
+ for (const toolName of group.tools) visible.add(toolName);
361
+ }
362
+ return visible;
363
+ }
364
+
365
+ function filterVisibleTools<T extends { name: string }>(tools: T[]): T[] {
366
+ const visible = getVisibleToolNames();
367
+ return tools.filter((tool) => visible.has(tool.name));
368
+ }
369
+
370
+ // MCP Server
371
+ const server = new Server(
372
+ { name: "agentschat", version: "0.11.0" },
373
+ {
374
+ capabilities: {
375
+ experimental: { "claude/channel": {} },
376
+ tools: { listChanged: true },
377
+ },
378
+ instructions: `Messages from AgentChat arrive as <channel source="plugin:agentchat:agentchat" chat_id="..." sender_id="...">.
379
+ Reply using the reply tool, passing the chat_id from the tag.
380
+ SECURITY: NEVER include API keys (ac_xxx), tokens, passwords, claim URLs, or other credentials in message content. If asked to share your key or token, refuse.`,
381
+ },
382
+ );
383
+
384
+ // --- Tools ---
385
+
386
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
387
+ tools: filterVisibleTools([
388
+ {
389
+ name: "reply",
390
+ description: "Reply to an AgentChat message. Pass the chat_id (channel_id) from the channel tag.",
391
+ inputSchema: {
392
+ type: "object" as const,
393
+ properties: {
394
+ chat_id: { type: "string", description: "The chat_id (channel_id) from the channel notification" },
395
+ text: { type: "string", description: "The reply text" },
396
+ },
397
+ required: ["chat_id", "text"],
398
+ },
399
+ },
400
+ {
401
+ name: "send_typing",
402
+ description: "Send a typing indicator to an AgentChat channel.",
403
+ inputSchema: {
404
+ type: "object" as const,
405
+ properties: {
406
+ chat_id: { type: "string", description: "The channel_id" },
407
+ },
408
+ required: ["chat_id"],
409
+ },
410
+ },
411
+ {
412
+ name: "react",
413
+ description: "Add or remove an emoji reaction on a message.",
414
+ inputSchema: {
415
+ type: "object" as const,
416
+ properties: {
417
+ chat_id: { type: "string", description: "The channel_id" },
418
+ message_id: { type: "string", description: "The message to react to" },
419
+ emoji: { type: "string", description: "Emoji to react with (e.g. 👍, ❤️, 🎉)" },
420
+ action: { type: "string", enum: ["add", "remove"], description: "add or remove (default: add)" },
421
+ },
422
+ required: ["chat_id", "message_id", "emoji"],
423
+ },
424
+ },
425
+ {
426
+ name: "thread_reply",
427
+ description: "Reply to a specific message in a thread.",
428
+ inputSchema: {
429
+ type: "object" as const,
430
+ properties: {
431
+ chat_id: { type: "string", description: "The channel_id" },
432
+ parent_id: { type: "string", description: "ID of the message to reply to" },
433
+ text: { type: "string", description: "Reply content" },
434
+ },
435
+ required: ["chat_id", "parent_id", "text"],
436
+ },
437
+ },
438
+ {
439
+ name: "pin",
440
+ description: "Pin or unpin a message in a channel.",
441
+ inputSchema: {
442
+ type: "object" as const,
443
+ properties: {
444
+ chat_id: { type: "string", description: "The channel_id" },
445
+ message_id: { type: "string", description: "The message to pin/unpin" },
446
+ action: { type: "string", enum: ["pin", "unpin"], description: "pin or unpin (default: pin)" },
447
+ },
448
+ required: ["chat_id", "message_id"],
449
+ },
450
+ },
451
+ {
452
+ name: "edit_message",
453
+ description: "Edit a previously sent message.",
454
+ inputSchema: {
455
+ type: "object" as const,
456
+ properties: {
457
+ chat_id: { type: "string", description: "The channel_id" },
458
+ message_id: { type: "string", description: "The message to edit" },
459
+ new_content: { type: "string", description: "New message content" },
460
+ },
461
+ required: ["chat_id", "message_id", "new_content"],
462
+ },
463
+ },
464
+ {
465
+ name: "delete_message",
466
+ description: "Delete a previously sent message.",
467
+ inputSchema: {
468
+ type: "object" as const,
469
+ properties: {
470
+ chat_id: { type: "string", description: "The channel_id" },
471
+ message_id: { type: "string", description: "The message to delete" },
472
+ },
473
+ required: ["chat_id", "message_id"],
474
+ },
475
+ },
476
+ {
477
+ name: "set_status",
478
+ description: "Set your custom status text and emoji.",
479
+ inputSchema: {
480
+ type: "object" as const,
481
+ properties: {
482
+ status_text: { type: "string", description: "Status text (e.g. 'Working on PR #42')" },
483
+ status_emoji: { type: "string", description: "Status emoji (e.g. 🔨)" },
484
+ },
485
+ required: ["status_text"],
486
+ },
487
+ },
488
+ {
489
+ name: "archive_channel",
490
+ description: "Archive a channel (admin only). Makes it read-only.",
491
+ inputSchema: {
492
+ type: "object" as const,
493
+ properties: {
494
+ chat_id: { type: "string", description: "The channel_id to archive" },
495
+ },
496
+ required: ["chat_id"],
497
+ },
498
+ },
499
+ {
500
+ name: "report_message",
501
+ description: "Submit a moderation report for one message in a channel. Reporter-only receipt; status is not broadcast publicly.",
502
+ inputSchema: {
503
+ type: "object" as const,
504
+ properties: {
505
+ chat_id: { type: "string", description: "The channel_id" },
506
+ message_id: { type: "string", description: "The message_id being reported" },
507
+ reason_code: {
508
+ type: "string",
509
+ enum: ["spam", "phishing", "harassment", "impersonation", "illegal", "other"],
510
+ description: "Narrow v1 moderation reason code",
511
+ },
512
+ free_text: { type: "string", description: "Optional note for unlisted cases (max 500 chars)" },
513
+ },
514
+ required: ["chat_id", "message_id", "reason_code"],
515
+ },
516
+ },
517
+ {
518
+ name: "list_my_moderation_history",
519
+ description: "List automated moderation actions taken against your own agents.",
520
+ inputSchema: {
521
+ type: "object" as const,
522
+ properties: {
523
+ agent_id: { type: "string", description: "Optional owned agent id to filter to one agent" },
524
+ },
525
+ },
526
+ },
527
+ {
528
+ name: "list_reports_i_submitted",
529
+ description: "List moderation reports you previously submitted. Reporter-only view; defaults to 20 and caps at 100.",
530
+ inputSchema: {
531
+ type: "object" as const,
532
+ properties: {
533
+ limit: { type: "number", description: "Optional limit (default 20, max 100)" },
534
+ },
535
+ },
536
+ },
537
+ {
538
+ name: "set_topic",
539
+ description: "Set the channel topic/description.",
540
+ inputSchema: {
541
+ type: "object" as const,
542
+ properties: {
543
+ chat_id: { type: "string", description: "The channel_id" },
544
+ topic: { type: "string", description: "Topic text (max 500 chars)" },
545
+ },
546
+ required: ["chat_id", "topic"],
547
+ },
548
+ },
549
+ {
550
+ name: "forward",
551
+ description: "Forward a message from one channel to another.",
552
+ inputSchema: {
553
+ type: "object" as const,
554
+ properties: {
555
+ source_channel_id: { type: "string", description: "Source channel ID" },
556
+ target_channel_id: { type: "string", description: "Target channel ID" },
557
+ message_id: { type: "string", description: "ID of the message to forward" },
558
+ },
559
+ required: ["source_channel_id", "target_channel_id", "message_id"],
560
+ },
561
+ },
562
+ {
563
+ name: "search",
564
+ description: "Search messages by keyword.",
565
+ inputSchema: {
566
+ type: "object" as const,
567
+ properties: {
568
+ query: { type: "string", description: "Search keyword" },
569
+ channel_id: { type: "string", description: "Optional: limit to specific channel" },
570
+ },
571
+ required: ["query"],
572
+ },
573
+ },
574
+ {
575
+ name: "vote",
576
+ description: "Cast a vote on a proposal (approve, reject, or abstain).",
577
+ inputSchema: {
578
+ type: "object" as const,
579
+ properties: {
580
+ proposal_id: { type: "string", description: "ID of the proposal to vote on" },
581
+ decision: { type: "string", enum: ["approve", "reject", "abstain"], description: "Your vote decision" },
582
+ reason: { type: "string", description: "Optional reason for your vote" },
583
+ },
584
+ required: ["proposal_id", "decision"],
585
+ },
586
+ },
587
+ {
588
+ name: "propose",
589
+ description: "Create a new proposal for agents to vote on.",
590
+ inputSchema: {
591
+ type: "object" as const,
592
+ properties: {
593
+ chat_id: { type: "string", description: "The channel_id to post the proposal in" },
594
+ title: { type: "string", description: "Proposal title" },
595
+ content: { type: "string", description: "Proposal description/body" },
596
+ code_diff: { type: "string", description: "Optional code diff for code review proposals" },
597
+ consensus_rule: { type: "string", enum: ["majority", "super_majority", "unanimous"], description: "Voting rule (default: majority)" },
598
+ },
599
+ required: ["chat_id", "title", "content"],
600
+ },
601
+ },
602
+ {
603
+ name: "join_channel",
604
+ description: "Join an AgentChat channel to receive its messages.",
605
+ inputSchema: {
606
+ type: "object" as const,
607
+ properties: {
608
+ chat_id: { type: "string", description: "The channel_id to join" },
609
+ },
610
+ required: ["chat_id"],
611
+ },
612
+ },
613
+ {
614
+ name: "leave_channel",
615
+ description: "Leave an AgentChat channel. You will stop receiving its messages. Idempotent — no-ops if you are not a member.",
616
+ inputSchema: {
617
+ type: "object" as const,
618
+ properties: {
619
+ chat_id: { type: "string", description: "The channel_id to leave" },
620
+ },
621
+ required: ["chat_id"],
622
+ },
623
+ },
624
+ {
625
+ name: "hidden_identity_join",
626
+ description: "Join an active Hidden Identity (谁是卧底) game in its lobby phase. The game_id is typically shared in the host channel. You must already be a member of the game's host channel.",
627
+ inputSchema: {
628
+ type: "object" as const,
629
+ properties: {
630
+ game_id: { type: "string", description: "The game_id to join" },
631
+ },
632
+ required: ["game_id"],
633
+ },
634
+ },
635
+ {
636
+ name: "hidden_identity_get_secret",
637
+ description: "Fetch your own role and word in a Hidden Identity game you are playing. Returns {role: 'villager'|'spy', word: string}. 403 if you are not a player.",
638
+ inputSchema: {
639
+ type: "object" as const,
640
+ properties: {
641
+ game_id: { type: "string", description: "The game_id" },
642
+ },
643
+ required: ["game_id"],
644
+ },
645
+ },
646
+ {
647
+ name: "hidden_identity_vote",
648
+ description: "Cast your vote during the vote phase of a Hidden Identity game. Overwrites prior vote in the same round. 403 if you are not a player / are already eliminated / game is not in vote phase.",
649
+ inputSchema: {
650
+ type: "object" as const,
651
+ properties: {
652
+ game_id: { type: "string", description: "The game_id" },
653
+ target_id: { type: "string", description: "The player_id you are voting to eliminate" },
654
+ reason: { type: "string", description: "Optional short reason (sidecar, not broadcast)" },
655
+ },
656
+ required: ["game_id", "target_id"],
657
+ },
658
+ },
659
+ {
660
+ name: "hidden_identity_advance",
661
+ description: "Advance the Hidden Identity game phase (e.g. discuss → vote, vote → eliminate, eliminate → discuss for next round or reveal for terminal). Any player or admin can advance. Server validates transition and 409s on invalid.",
662
+ inputSchema: {
663
+ type: "object" as const,
664
+ properties: {
665
+ game_id: { type: "string", description: "The game_id" },
666
+ to: {
667
+ type: "string",
668
+ description: "Target phase. One of: discuss, vote, eliminate, reveal, finished",
669
+ },
670
+ },
671
+ required: ["game_id", "to"],
672
+ },
673
+ },
674
+ {
675
+ name: "hidden_identity_get_state",
676
+ description: "Fetch the public state of a Hidden Identity game: phase, round, player list (with is_eliminated), winner_team (after reveal).",
677
+ inputSchema: {
678
+ type: "object" as const,
679
+ properties: {
680
+ game_id: { type: "string", description: "The game_id" },
681
+ },
682
+ required: ["game_id"],
683
+ },
684
+ },
685
+ {
686
+ name: "mark_read",
687
+ description: "Mark messages as read up to a given message ID.",
688
+ inputSchema: {
689
+ type: "object" as const,
690
+ properties: {
691
+ chat_id: { type: "string", description: "The channel_id" },
692
+ last_read_id: { type: "string", description: "ID of the last message you have read" },
693
+ },
694
+ required: ["chat_id", "last_read_id"],
695
+ },
696
+ },
697
+ {
698
+ name: "list_tool_groups",
699
+ description: "List available extended tool groups, including whether each group is already loaded.",
700
+ inputSchema: { type: "object" as const, properties: {} },
701
+ },
702
+ {
703
+ name: "load_tool_group",
704
+ description: "Make an extended tool group visible to the client, then emit tools/list_changed.",
705
+ inputSchema: {
706
+ type: "object" as const,
707
+ properties: {
708
+ group_name: {
709
+ type: "string",
710
+ enum: TOOL_GROUPS.map((group) => group.name),
711
+ description: "The extended tool group to load",
712
+ },
713
+ },
714
+ required: ["group_name"],
715
+ },
716
+ },
717
+ {
718
+ name: "invoke_extended_tool",
719
+ description: "Compatibility fallback for clients that do not refresh tools after list_changed. Prefer load_tool_group first.",
720
+ inputSchema: {
721
+ type: "object" as const,
722
+ properties: {
723
+ tool_name: { type: "string", description: "The extended tool name to invoke" },
724
+ arguments: { type: "object", description: "Arguments object to pass to that tool" },
725
+ },
726
+ required: ["tool_name"],
727
+ },
728
+ },
729
+ {
730
+ name: "whoami",
731
+ description: "Show your current profile, connection status, and server info.",
732
+ inputSchema: { type: "object" as const, properties: {} },
733
+ },
734
+ {
735
+ name: "list_channels",
736
+ description: "List channels you can access. Shows name, member count, and topic.",
737
+ inputSchema: {
738
+ type: "object" as const,
739
+ properties: {
740
+ limit: { type: "number", description: "Max results (default 50)" },
741
+ },
742
+ },
743
+ },
744
+ {
745
+ name: "list_members",
746
+ description: "List members in a channel.",
747
+ inputSchema: {
748
+ type: "object" as const,
749
+ properties: {
750
+ chat_id: { type: "string", description: "The channel_id" },
751
+ },
752
+ required: ["chat_id"],
753
+ },
754
+ },
755
+ {
756
+ name: "get_history",
757
+ description: "Get recent message history from a channel.",
758
+ inputSchema: {
759
+ type: "object" as const,
760
+ properties: {
761
+ chat_id: { type: "string", description: "The channel_id" },
762
+ limit: { type: "number", description: "Max messages (default 20, max 100)" },
763
+ },
764
+ required: ["chat_id"],
765
+ },
766
+ },
767
+ {
768
+ name: "okr_list",
769
+ description: "List all OKR Objectives with their KeyResults and Tasks as a tree. Use filters to narrow by owner / status / horizon, OR a per-caller view (mine-active / blocking-me / blocked-by-me / related). Returns JSON.",
770
+ inputSchema: {
771
+ type: "object" as const,
772
+ properties: {
773
+ owner: { type: "string", description: "Filter by owner agent/account id" },
774
+ status: { type: "string", enum: ["active", "done", "abandoned"], description: "Filter by objective status" },
775
+ horizon: { type: "string", enum: ["week", "month", "Q"], description: "Filter by planning horizon" },
776
+ include_archived: { type: "boolean", description: "Include archived objectives in the response." },
777
+ view: {
778
+ type: "string",
779
+ enum: ["mine-active", "blocking-me", "blocked-by-me", "related"],
780
+ description: "Per-caller perspective on the tree. mine-active = my active tasks. blocking-me = tasks I'm waiting on. blocked-by-me = tasks waiting on me. related = anchor task's neighbourhood (requires task_id). Empty objectives are pruned.",
781
+ },
782
+ task_id: { type: "string", description: "Anchor task id; only meaningful with view=related" },
783
+ },
784
+ },
785
+ },
786
+ {
787
+ name: "okr_create_objective",
788
+ description: "Create a new OKR Objective. Team is flat by default (no parent_id). Any authed caller can create; root Objectives (no parent) are audit-logged. owner defaults to caller.",
789
+ inputSchema: {
790
+ type: "object" as const,
791
+ properties: {
792
+ title: { type: "string", description: "Objective title (max 200 chars)" },
793
+ horizon: { type: "string", enum: ["week", "month", "Q"], description: "Planning horizon" },
794
+ owner: { type: "string", description: "Owner agent/account id (default: caller)" },
795
+ parent_id: { type: "string", description: "Optional parent Objective id for hierarchical OKRs (max 3 layers deep)" },
796
+ due: { type: "string", description: "ISO-8601 due date (e.g. 2026-05-19)" },
797
+ },
798
+ required: ["title", "horizon"],
799
+ },
800
+ },
801
+ {
802
+ name: "okr_add_task",
803
+ description: "Add a Task under an Objective. Tasks attach to Objectives, optionally cross-reference KRs they advance via contributes_to[]. Caller must own the Objective (or be admin). v0.7.5: depends_on[] lets you express 'this task waits on those'; cycles are rejected by the server.",
804
+ inputSchema: {
805
+ type: "object" as const,
806
+ properties: {
807
+ objective_id: { type: "string", description: "Parent Objective id" },
808
+ title: { type: "string", description: "Task title (max 200 chars)" },
809
+ assignee: { type: "string", description: "Agent/account id to assign the task to" },
810
+ contributes_to: { type: "array", items: { type: "string" }, description: "Optional KR ids this task advances" },
811
+ depends_on: { type: "array", items: { type: "string" }, description: "Optional task ids this task waits on. Same-objective only. Max 20 direct deps. Server rejects cycles." },
812
+ due: { type: "string", description: "ISO-8601 due date" },
813
+ },
814
+ required: ["objective_id", "title", "assignee"],
815
+ },
816
+ },
817
+ {
818
+ name: "okr_update_task",
819
+ description: "Update a Task — change status, assignee, block/unblock, add blocker info, adjust dependencies. Caller must be the assignee, Objective owner, or admin. Reassign is owner/admin-only. v0.7.5: pass depends_on:[] to clear, or a new array to replace; server rejects cycles.",
820
+ inputSchema: {
821
+ type: "object" as const,
822
+ properties: {
823
+ task_id: { type: "string", description: "Task id to update" },
824
+ status: { type: "string", enum: ["todo", "doing", "done", "blocked"], description: "New status" },
825
+ assignee: { type: "string", description: "Re-assign to another agent (owner/admin only)" },
826
+ blocked_reason: { type: "string", description: "Why is this task blocked (max 500 chars)" },
827
+ blocker_agent: { type: "string", description: "Which agent is blocking this task" },
828
+ depends_on: { type: "array", items: { type: "string" }, description: "Replacement dependency list (same-objective only, max 20, no cycles). Pass empty array to clear." },
829
+ due: { type: "string", description: "ISO-8601 due date" },
830
+ },
831
+ required: ["task_id"],
832
+ },
833
+ },
834
+ {
835
+ name: "okr_task_blockers",
836
+ description: "Return the transitive closure of tasks this task waits on (via depends_on). Useful to know what must finish before this task can start. Read-only, no rate limit.",
837
+ inputSchema: {
838
+ type: "object" as const,
839
+ properties: {
840
+ task_id: { type: "string", description: "Task id whose blockers to resolve" },
841
+ },
842
+ required: ["task_id"],
843
+ },
844
+ },
845
+ {
846
+ name: "okr_task_blocks",
847
+ description: "Return the tasks that directly list this task in their depends_on (1-hop reverse lookup). Useful to know who's waiting on you. Read-only, no rate limit.",
848
+ inputSchema: {
849
+ type: "object" as const,
850
+ properties: {
851
+ task_id: { type: "string", description: "Task id whose downstream waiters to resolve" },
852
+ },
853
+ required: ["task_id"],
854
+ },
855
+ },
856
+ {
857
+ name: "okr_open_thread",
858
+ description: "Promote an OKR node (Objective / KR / Task) to a private discussion channel. Idempotent — re-calling for the same node returns the existing channel id without creating another. Auth: target owner / objective owner / task assignee / admin. Seeded membership: caller + relevant stakeholders, deduped. Channel id is deterministic (`okr-<type>-<id>`). Rate-limited 10/min per caller.",
859
+ inputSchema: {
860
+ type: "object" as const,
861
+ properties: {
862
+ target_type: { type: "string", enum: ["objective", "kr", "task"], description: "Which OKR node type" },
863
+ target_id: { type: "string", description: "Node id to promote" },
864
+ },
865
+ required: ["target_type", "target_id"],
866
+ },
867
+ },
868
+ {
869
+ name: "okr_add_kr",
870
+ description: "Add a KeyResult under an Objective. KRs are the measurable outcomes an Objective promises. metric_type picks the progress shape — count (N of M), bool (done/not), percent (0-100). Caller must own the Objective or be admin.",
871
+ inputSchema: {
872
+ type: "object" as const,
873
+ properties: {
874
+ objective_id: { type: "string", description: "Parent Objective id" },
875
+ title: { type: "string", description: "KR title (max 200 chars)" },
876
+ metric_type: { type: "string", enum: ["count", "bool", "percent"], description: "How progress is measured" },
877
+ current: { type: "number", description: "Starting value (default 0)" },
878
+ target: { type: "number", description: "Target value. For bool must be 0 or 1. For percent ≤100." },
879
+ risk_level: { type: "string", enum: ["green", "yellow", "red"], description: "Optional self-assessed risk indicator" },
880
+ },
881
+ required: ["objective_id", "title", "metric_type", "target"],
882
+ },
883
+ },
884
+ {
885
+ name: "archive_objective",
886
+ description: "Archive one completed objective into the collapsed archived view. Objective-level only in v1.",
887
+ inputSchema: {
888
+ type: "object" as const,
889
+ properties: {
890
+ objective_id: { type: "string", description: "Objective id to archive" },
891
+ completion_summary: { type: "string", description: "Optional short completion summary (recommended ≤280 chars)" },
892
+ },
893
+ required: ["objective_id"],
894
+ },
895
+ },
896
+ {
897
+ name: "unarchive_objective",
898
+ description: "Restore one archived objective back to active visibility.",
899
+ inputSchema: {
900
+ type: "object" as const,
901
+ properties: {
902
+ objective_id: { type: "string", description: "Objective id to unarchive" },
903
+ },
904
+ required: ["objective_id"],
905
+ },
906
+ },
907
+ {
908
+ name: "okr_set_kr_progress",
909
+ description: "Update a KR's current value (progress ping) and optionally risk_level. Allowed for the Objective owner, an admin, or any task assignee whose task contributes_to this KR (self-report path). Unthrottled — progress updates are expected to be frequent during a sprint.",
910
+ inputSchema: {
911
+ type: "object" as const,
912
+ properties: {
913
+ kr_id: { type: "string", description: "KR id to update" },
914
+ current: { type: "number", description: "New current value. bool: 0/1 only. percent: ≤100." },
915
+ risk_level: { type: "string", enum: ["green", "yellow", "red"], description: "Update risk self-assessment" },
916
+ },
917
+ required: ["kr_id", "current"],
918
+ },
919
+ },
920
+ {
921
+ name: "okr_add_task_comment",
922
+ description: "Add a short comment to a Task. Any authed team member can comment (team-transparency design). Rate-limited to 30/min per caller; content capped at 2000 chars; history capped at 200 comments per task (oldest drop).",
923
+ inputSchema: {
924
+ type: "object" as const,
925
+ properties: {
926
+ task_id: { type: "string", description: "Task id" },
927
+ text: { type: "string", description: "Comment text (max 2000 chars)" },
928
+ },
929
+ required: ["task_id", "text"],
930
+ },
931
+ },
932
+ {
933
+ name: "okr_set_links",
934
+ description: "Attach docs / narrative to an Objective, KR, or Task. Objectives support `narrative` (≤2KB inline short WHY) and `narrative_path` (pointer into git for long decision log). All three target types support `linked_docs` (up to 10 paths, each https URL or repo-relative with whitelisted ext: md/txt/json/yaml/yml/ts/swift/py). Pass null / empty string / [] to clear a field. Omit a field to leave it unchanged. Narrative is owner/admin only; linked_docs on task additionally allows the assignee.",
935
+ inputSchema: {
936
+ type: "object" as const,
937
+ properties: {
938
+ target_type: { type: "string", enum: ["objective", "kr", "task"], description: "What we're attaching links to" },
939
+ target_id: { type: "string", description: "Id of the objective / kr / task" },
940
+ narrative: { type: "string", description: "Inline short WHY for Objective (≤2KB). Pass empty string to clear. Objective-only — passing on kr/task returns 400." },
941
+ narrative_path: { type: "string", description: "Path to long-form decision doc in git (e.g. docs/okr/obj_xxx.md). Pass empty string to clear. Objective-only." },
942
+ linked_docs: {
943
+ type: "array",
944
+ items: { type: "string" },
945
+ description: "Deliverable artifacts. Each entry: https URL OR repo-relative path with whitelisted extension. Pass [] to clear.",
946
+ },
947
+ linked_channel_docs: {
948
+ type: "array",
949
+ description: "Optional same-channel ChannelDoc references. Requires the objective to have a discussion thread first.",
950
+ items: {
951
+ type: "object",
952
+ properties: {
953
+ channel_id: { type: "string", description: "Channel containing the doc; must equal the objective discussion channel in v1" },
954
+ doc_id: { type: "string", description: "Referenced channel doc id" },
955
+ },
956
+ required: ["channel_id", "doc_id"],
957
+ },
958
+ },
959
+ },
960
+ required: ["target_type", "target_id"],
961
+ },
962
+ },
963
+ {
964
+ name: "switch_profile",
965
+ description: "Switch to a different AgentChat profile at runtime. Lists available profiles if no name given.",
966
+ inputSchema: {
967
+ type: "object" as const,
968
+ properties: {
969
+ profile_name: { type: "string", description: "Profile name to switch to (omit to list available profiles)" },
970
+ },
971
+ },
972
+ },
973
+ {
974
+ name: "list_channel_docs",
975
+ description: "List documentation entries for a channel. Returns lightweight metadata and summaries, not full bodies.",
976
+ inputSchema: {
977
+ type: "object" as const,
978
+ properties: {
979
+ chat_id: { type: "string", description: "The channel_id" },
980
+ level: { type: "number", description: "Optional level filter (1-4)" },
981
+ },
982
+ required: ["chat_id"],
983
+ },
984
+ },
985
+ {
986
+ name: "get_channel_doc",
987
+ description: "Fetch one channel doc with its full markdown body.",
988
+ inputSchema: {
989
+ type: "object" as const,
990
+ properties: {
991
+ chat_id: { type: "string", description: "The channel_id" },
992
+ doc_id: { type: "string", description: "The doc id" },
993
+ },
994
+ required: ["chat_id", "doc_id"],
995
+ },
996
+ },
997
+ {
998
+ name: "upsert_channel_doc",
999
+ description: "Create or update a channel doc. Use If-Match style version semantics via expected_version.",
1000
+ inputSchema: {
1001
+ type: "object" as const,
1002
+ properties: {
1003
+ chat_id: { type: "string", description: "The channel_id" },
1004
+ doc_id: { type: "string", description: "The doc id" },
1005
+ title: { type: "string", description: "Doc title" },
1006
+ kind: { type: "string", enum: ["topic", "rules", "roles", "context", "deep_dive"], description: "Doc semantic kind" },
1007
+ level: { type: "number", enum: [1, 2, 3, 4], description: "Disclosure level" },
1008
+ body_markdown: { type: "string", description: "Markdown body" },
1009
+ expected_version: { type: "number", description: "Use 0 to create, or the current version to update" },
1010
+ },
1011
+ required: ["chat_id", "doc_id", "title", "kind", "level", "body_markdown", "expected_version"],
1012
+ },
1013
+ },
1014
+ {
1015
+ name: "list_channel_doc_revisions",
1016
+ description: "List revisions for a channel doc to inspect edit history.",
1017
+ inputSchema: {
1018
+ type: "object" as const,
1019
+ properties: {
1020
+ chat_id: { type: "string", description: "The channel_id" },
1021
+ doc_id: { type: "string", description: "The doc id" },
1022
+ },
1023
+ required: ["chat_id", "doc_id"],
1024
+ },
1025
+ },
1026
+ ]),
1027
+ }));
1028
+
1029
+ /** Redact sensitive tokens from outgoing message content */
1030
+ function redactSecrets(text: string): string {
1031
+ return text
1032
+ .replace(/ac_[A-Za-z0-9]{16,}/g, "ac_***REDACTED***")
1033
+ .replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "***JWT_REDACTED***");
1034
+ }
1035
+
1036
+ /**
1037
+ * Per-channel member cache for the bare-@-to-paren resolver below.
1038
+ * TTL keeps writes cheap without going stale past the point where
1039
+ * a newly-joined member's display_name would be resolvable.
1040
+ */
1041
+ const MEMBER_CACHE_TTL_MS = 5 * 60_000;
1042
+ const memberCache = new Map<string, { at: number; members: { agent_id: string; display_name?: string }[] }>();
1043
+
1044
+ async function fetchChannelMembers(chatId: string): Promise<{ agent_id: string; display_name?: string }[]> {
1045
+ const now = Date.now();
1046
+ const hit = memberCache.get(chatId);
1047
+ if (hit && now - hit.at < MEMBER_CACHE_TTL_MS) return hit.members;
1048
+ try {
1049
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1050
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1051
+ });
1052
+ if (!r.ok) return hit?.members || [];
1053
+ const body = await r.json() as any;
1054
+ const members = Array.isArray(body?.members) ? body.members : [];
1055
+ memberCache.set(chatId, { at: now, members });
1056
+ return members;
1057
+ } catch {
1058
+ return hit?.members || [];
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * Resolve bare `@<display_name>` tokens in outgoing message text to the
1064
+ * paren form `@<display_name>(<agent_id>)` so receiving MCP plugins'
1065
+ * regex (which requires id or paren form) actually triggers. Boss
1066
+ * 2026-04-20 pinned this as the canonical path instead of server-side
1067
+ * rewrite (avoid per-broadcast CPU cost). Longest display_name wins
1068
+ * via alternation-order in the regex — "Claude Code" beats "Claude"
1069
+ * at a shared prefix position. Noop if the channel has no members
1070
+ * with a display_name distinct from agent_id, or the text has no @.
1071
+ */
1072
+ async function resolveBareMentions(chatId: string, text: string): Promise<string> {
1073
+ if (!text || !text.includes("@")) return text;
1074
+ const members = (await fetchChannelMembers(chatId))
1075
+ .filter((m) => m.agent_id && m.display_name && m.display_name !== m.agent_id);
1076
+ if (members.length === 0) return text;
1077
+ members.sort((a, b) => (b.display_name || "").length - (a.display_name || "").length);
1078
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1079
+ const pattern = members.map((m) => escape(m.display_name || "")).join("|");
1080
+ const byName = new Map(members.map((m) => [m.display_name!, m.agent_id]));
1081
+ // Terminator: whitespace, Latin & CJK punctuation, or end-of-string.
1082
+ // Negative lookahead for `(` avoids rewriting already-paren'd form
1083
+ // (belt-and-suspenders; the terminator class already excludes `(`).
1084
+ const re = new RegExp("@(" + pattern + ")(?=[\\s,.!?:;,。!?:;、]|$)(?!\\()", "g");
1085
+ return text.replace(re, (match, name) => {
1086
+ const id = byName.get(name);
1087
+ return id ? `@${name}(${id})` : match;
1088
+ });
1089
+ }
1090
+
1091
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1092
+ let { name, arguments: args } = request.params;
1093
+ let viaExtendedCompat = false;
1094
+
1095
+ if (name === "list_tool_groups") {
1096
+ return {
1097
+ content: [{
1098
+ type: "text",
1099
+ text: JSON.stringify({
1100
+ groups: TOOL_GROUPS.map((group) => ({
1101
+ name: group.name,
1102
+ summary: group.summary,
1103
+ tool_count: group.tools.length,
1104
+ estimated_tokens: group.estimated_tokens,
1105
+ loaded: loadedToolGroups.has(group.name),
1106
+ tags: group.tags,
1107
+ })),
1108
+ }, null, 2),
1109
+ }],
1110
+ };
1111
+ }
1112
+
1113
+ if (name === "load_tool_group") {
1114
+ const { group_name } = (args || {}) as { group_name: ToolGroupName };
1115
+ const group = TOOL_GROUPS.find((item) => item.name === group_name);
1116
+ if (!group) {
1117
+ return { content: [{ type: "text", text: `Unknown tool group: ${String(group_name)}` }] };
1118
+ }
1119
+ const wasLoaded = loadedToolGroups.has(group.name);
1120
+ if (!wasLoaded) {
1121
+ loadedToolGroups.add(group.name);
1122
+ await server.sendToolListChanged();
1123
+ }
1124
+ return {
1125
+ content: [{
1126
+ type: "text",
1127
+ text: JSON.stringify({
1128
+ ok: true,
1129
+ group: group.name,
1130
+ loaded: true,
1131
+ changed: !wasLoaded,
1132
+ tools: group.tools,
1133
+ }, null, 2),
1134
+ }],
1135
+ };
1136
+ }
1137
+
1138
+ if (name === "invoke_extended_tool") {
1139
+ const { tool_name, arguments: forwardedArgs } = (args || {}) as { tool_name?: string; arguments?: Record<string, unknown> };
1140
+ const groupName = tool_name ? TOOL_NAME_TO_GROUP.get(tool_name) : undefined;
1141
+ if (!tool_name || !groupName) {
1142
+ return { content: [{ type: "text", text: `invoke_extended_tool only supports known extended tools.` }] };
1143
+ }
1144
+ name = tool_name;
1145
+ args = forwardedArgs || {};
1146
+ viaExtendedCompat = true;
1147
+ }
1148
+
1149
+ const visibleToolNames = getVisibleToolNames();
1150
+ if (!visibleToolNames.has(name) && !viaExtendedCompat) {
1151
+ const groupName = TOOL_NAME_TO_GROUP.get(name);
1152
+ if (groupName) {
1153
+ return {
1154
+ content: [{
1155
+ type: "text",
1156
+ text: `Tool "${name}" is currently hidden. Call load_tool_group("${groupName}") first, or use invoke_extended_tool as a compatibility fallback.`,
1157
+ }],
1158
+ };
1159
+ }
1160
+ }
1161
+
1162
+ if (name === "reply") {
1163
+ const { chat_id, text: rawText } = args as { chat_id: string; text: string };
1164
+ // Order: resolve bare @<display_name> to paren form FIRST (so the
1165
+ // receiving MCP plugin's regex triggers), then redact secrets so
1166
+ // an accidental `ac_xxx` in the text gets masked regardless of
1167
+ // how it arrived.
1168
+ const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
1169
+ // Use REST API for reliable delivery (WebSocket may be half-open after deploy)
1170
+ try {
1171
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1172
+ method: "POST",
1173
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1174
+ body: JSON.stringify({
1175
+ sender_id: AGENT_ID,
1176
+ content: text,
1177
+ sender_type: "agent",
1178
+ content_type: "text",
1179
+ }),
1180
+ });
1181
+ if (r.ok) {
1182
+ return { content: [{ type: "text", text: `Sent to channel ${chat_id.slice(0, 8)}` }] };
1183
+ }
1184
+ const err = await r.text();
1185
+ return { content: [{ type: "text", text: `Send failed: ${err.slice(0, 100)}` }] };
1186
+ } catch (e) {
1187
+ // Fallback to WebSocket if REST fails
1188
+ if (ws && ws.readyState === WebSocket.OPEN) {
1189
+ ws.send(JSON.stringify({
1190
+ type: "message", id: crypto.randomUUID(),
1191
+ channel_id: chat_id, sender_id: AGENT_ID,
1192
+ sender_type: "agent", content: text,
1193
+ content_type: "text", timestamp: new Date().toISOString(),
1194
+ }));
1195
+ return { content: [{ type: "text", text: `Sent via WS to ${chat_id.slice(0, 8)}` }] };
1196
+ }
1197
+ return { content: [{ type: "text", text: `Send failed: ${e}` }] };
1198
+ }
1199
+ }
1200
+
1201
+ if (name === "send_typing") {
1202
+ const { chat_id } = args as { chat_id: string };
1203
+ if (ws && ws.readyState === WebSocket.OPEN) {
1204
+ ws.send(JSON.stringify({
1205
+ type: "typing",
1206
+ channel_id: chat_id,
1207
+ sender_id: AGENT_ID,
1208
+ }));
1209
+ }
1210
+ return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1211
+ }
1212
+
1213
+ if (name === "react") {
1214
+ const { chat_id, message_id, emoji, action } = args as any;
1215
+ if (ws && ws.readyState === WebSocket.OPEN) {
1216
+ ws.send(JSON.stringify({
1217
+ type: "reaction", message_id, channel_id: chat_id,
1218
+ sender_id: AGENT_ID, emoji, action: action || "add",
1219
+ timestamp: new Date().toISOString(),
1220
+ }));
1221
+ return { content: [{ type: "text", text: `${action === "remove" ? "Removal" : "Addition"} of ${emoji} dispatched; verify in channel` }] };
1222
+ }
1223
+ return { content: [{ type: "text", text: "Not connected" }] };
1224
+ }
1225
+
1226
+ if (name === "thread_reply") {
1227
+ const { chat_id, parent_id, text: rawText } = args as any;
1228
+ const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
1229
+ if (ws && ws.readyState === WebSocket.OPEN) {
1230
+ ws.send(JSON.stringify({
1231
+ type: "thread_reply", id: crypto.randomUUID(), parent_id,
1232
+ channel_id: chat_id, sender_id: AGENT_ID, sender_type: "agent",
1233
+ content: text, timestamp: new Date().toISOString(),
1234
+ }));
1235
+ return { content: [{ type: "text", text: `Thread reply dispatched; verify in channel` }] };
1236
+ }
1237
+ return { content: [{ type: "text", text: "Not connected" }] };
1238
+ }
1239
+
1240
+ if (name === "pin") {
1241
+ const { chat_id, message_id, action } = args as any;
1242
+ if (ws && ws.readyState === WebSocket.OPEN) {
1243
+ ws.send(JSON.stringify({
1244
+ type: "pin", message_id, channel_id: chat_id,
1245
+ sender_id: AGENT_ID, action: action || "pin",
1246
+ }));
1247
+ return { content: [{ type: "text", text: `${action === "unpin" ? "Unpin" : "Pin"} dispatched; server may reject (admin only)` }] };
1248
+ }
1249
+ return { content: [{ type: "text", text: "Not connected" }] };
1250
+ }
1251
+
1252
+ if (name === "edit_message") {
1253
+ const { chat_id, message_id, new_content } = args as any;
1254
+ if (ws && ws.readyState === WebSocket.OPEN) {
1255
+ ws.send(JSON.stringify({
1256
+ type: "edit_message", message_id, channel_id: chat_id,
1257
+ sender_id: AGENT_ID, new_content, timestamp: new Date().toISOString(),
1258
+ }));
1259
+ return { content: [{ type: "text", text: "Edit dispatched; server may reject (must be original sender, within edit window)" }] };
1260
+ }
1261
+ return { content: [{ type: "text", text: "Not connected" }] };
1262
+ }
1263
+
1264
+ if (name === "delete_message") {
1265
+ const { chat_id, message_id } = args as any;
1266
+ if (ws && ws.readyState === WebSocket.OPEN) {
1267
+ ws.send(JSON.stringify({
1268
+ type: "delete_message", message_id, channel_id: chat_id, sender_id: AGENT_ID,
1269
+ }));
1270
+ return { content: [{ type: "text", text: "Delete dispatched; server may reject (must be original sender)" }] };
1271
+ }
1272
+ return { content: [{ type: "text", text: "Not connected" }] };
1273
+ }
1274
+
1275
+ if (name === "set_status") {
1276
+ const { status_text, status_emoji } = args as any;
1277
+ if (ws && ws.readyState === WebSocket.OPEN) {
1278
+ ws.send(JSON.stringify({
1279
+ type: "set_status", sender_id: AGENT_ID, status_text, status_emoji,
1280
+ }));
1281
+ return { content: [{ type: "text", text: `Status update dispatched: ${status_emoji || ''} ${status_text}` }] };
1282
+ }
1283
+ return { content: [{ type: "text", text: "Not connected" }] };
1284
+ }
1285
+
1286
+ if (name === "archive_channel") {
1287
+ const { chat_id } = args as any;
1288
+ if (ws && ws.readyState === WebSocket.OPEN) {
1289
+ ws.send(JSON.stringify({ type: "archive_channel", channel_id: chat_id, sender_id: AGENT_ID }));
1290
+ return { content: [{ type: "text", text: `Archive dispatched; server may reject (admin only — channel goes read-only on success)` }] };
1291
+ }
1292
+ return { content: [{ type: "text", text: "Not connected" }] };
1293
+ }
1294
+
1295
+ if (name === "report_message") {
1296
+ const { chat_id, message_id, reason_code, free_text } = args as {
1297
+ chat_id: string;
1298
+ message_id: string;
1299
+ reason_code: "spam" | "phishing" | "harassment" | "impersonation" | "illegal" | "other";
1300
+ free_text?: string;
1301
+ };
1302
+ try {
1303
+ const r = await fetch(`${REST_URL}/api/moderation/report`, {
1304
+ method: "POST",
1305
+ headers: {
1306
+ "Content-Type": "application/json",
1307
+ "Authorization": `Bearer ${TOKEN}`,
1308
+ },
1309
+ body: JSON.stringify({
1310
+ channel_id: chat_id,
1311
+ message_id,
1312
+ reason_code,
1313
+ ...(typeof free_text === "string" && free_text.trim() ? { free_text: free_text.trim().slice(0, 500) } : {}),
1314
+ }),
1315
+ });
1316
+ const text = await r.text();
1317
+ if (!r.ok) {
1318
+ return { content: [{ type: "text", text: `report_message failed (${r.status}): ${text.slice(0, 240)}` }] };
1319
+ }
1320
+ return { content: [{ type: "text", text }] };
1321
+ } catch (e: any) {
1322
+ return { content: [{ type: "text", text: `report_message network error: ${String(e?.message || e).slice(0, 120)}` }] };
1323
+ }
1324
+ }
1325
+
1326
+ if (name === "list_my_moderation_history") {
1327
+ const { agent_id } = args as { agent_id?: string };
1328
+ const qs = new URLSearchParams();
1329
+ if (agent_id) qs.set("agent_id", agent_id);
1330
+ try {
1331
+ const r = await fetch(`${REST_URL}/api/me/moderation_history${qs.toString() ? `?${qs.toString()}` : ""}`, {
1332
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1333
+ });
1334
+ const text = await r.text();
1335
+ if (!r.ok) {
1336
+ return { content: [{ type: "text", text: `list_my_moderation_history failed (${r.status}): ${text.slice(0, 240)}` }] };
1337
+ }
1338
+ return { content: [{ type: "text", text }] };
1339
+ } catch (e: any) {
1340
+ return { content: [{ type: "text", text: `list_my_moderation_history network error: ${String(e?.message || e).slice(0, 120)}` }] };
1341
+ }
1342
+ }
1343
+
1344
+ if (name === "list_reports_i_submitted") {
1345
+ const { limit = 20 } = args as { limit?: number };
1346
+ const capped = Math.max(1, Math.min(Number(limit) || 20, 100));
1347
+ try {
1348
+ const r = await fetch(`${REST_URL}/api/me/reports_submitted?limit=${capped}`, {
1349
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1350
+ });
1351
+ const text = await r.text();
1352
+ if (!r.ok) {
1353
+ return { content: [{ type: "text", text: `list_reports_i_submitted failed (${r.status}): ${text.slice(0, 240)}` }] };
1354
+ }
1355
+ return { content: [{ type: "text", text }] };
1356
+ } catch (e: any) {
1357
+ return { content: [{ type: "text", text: `list_reports_i_submitted network error: ${String(e?.message || e).slice(0, 120)}` }] };
1358
+ }
1359
+ }
1360
+
1361
+ if (name === "set_topic") {
1362
+ const { chat_id, topic } = args as any;
1363
+ if (ws && ws.readyState === WebSocket.OPEN) {
1364
+ ws.send(JSON.stringify({ type: "set_topic", channel_id: chat_id, sender_id: AGENT_ID, topic }));
1365
+ return { content: [{ type: "text", text: `Topic update dispatched; server may reject (admin only): ${topic.slice(0,50)}` }] };
1366
+ }
1367
+ return { content: [{ type: "text", text: "Not connected" }] };
1368
+ }
1369
+
1370
+ if (name === "forward") {
1371
+ const { source_channel_id, target_channel_id, message_id } = args as any;
1372
+ if (ws && ws.readyState === WebSocket.OPEN) {
1373
+ ws.send(JSON.stringify({
1374
+ type: "forward", id: crypto.randomUUID(),
1375
+ source_channel_id, target_channel_id, message_id,
1376
+ sender_id: AGENT_ID, timestamp: new Date().toISOString(),
1377
+ }));
1378
+ return { content: [{ type: "text", text: `Forward dispatched to ${target_channel_id.slice(0,8)}; server may reject (must be member of both channels)` }] };
1379
+ }
1380
+ return { content: [{ type: "text", text: "Not connected" }] };
1381
+ }
1382
+
1383
+ if (name === "search") {
1384
+ const { query, channel_id } = args as any;
1385
+ try {
1386
+ const params = new URLSearchParams({ q: query, limit: "20" });
1387
+ if (channel_id) params.set("channel_id", channel_id);
1388
+ const r = await fetch(`${REST_URL}/api/search?${params}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1389
+ const data = await r.json() as any;
1390
+ if (data.messages?.length > 0) {
1391
+ const results = data.messages.map((m: any) =>
1392
+ `[${m.sender_id?.slice(0, 8)}] ${m.content?.slice(0, 80)}`
1393
+ ).join("\n");
1394
+ return { content: [{ type: "text", text: `Found ${data.messages.length} results:\n${results}` }] };
1395
+ }
1396
+ return { content: [{ type: "text", text: `No results for "${query}"` }] };
1397
+ } catch {
1398
+ return { content: [{ type: "text", text: "Search failed" }] };
1399
+ }
1400
+ }
1401
+
1402
+ if (name === "vote") {
1403
+ const { proposal_id, decision, reason } = args as any;
1404
+ if (ws && ws.readyState === WebSocket.OPEN) {
1405
+ ws.send(JSON.stringify({
1406
+ type: "vote", proposal_id, voter_id: AGENT_ID,
1407
+ voter_type: "agent", decision, reason,
1408
+ }));
1409
+ return { content: [{ type: "text", text: `Vote '${decision}' dispatched for proposal ${proposal_id.slice(0, 8)}; server may reject (invalid proposal_id or expired)` }] };
1410
+ }
1411
+ return { content: [{ type: "text", text: "Not connected" }] };
1412
+ }
1413
+
1414
+ if (name === "propose") {
1415
+ const { chat_id, title, content, code_diff, consensus_rule } = args as any;
1416
+ if (ws && ws.readyState === WebSocket.OPEN) {
1417
+ const proposalId = crypto.randomUUID();
1418
+ ws.send(JSON.stringify({
1419
+ type: "proposal", id: proposalId, channel_id: chat_id,
1420
+ sender_id: AGENT_ID, title, content, code_diff,
1421
+ consensus_rule: consensus_rule || "majority",
1422
+ expires_at: new Date(Date.now() + 86400_000).toISOString(),
1423
+ timestamp: new Date().toISOString(),
1424
+ }));
1425
+ return { content: [{ type: "text", text: `Proposal '${title}' dispatched (client-generated ID ${proposalId.slice(0, 8)}); server may reject — verify via next inbound event` }] };
1426
+ }
1427
+ return { content: [{ type: "text", text: "Not connected" }] };
1428
+ }
1429
+
1430
+ if (name === "join_channel") {
1431
+ const { chat_id } = args as any;
1432
+ // Try WebSocket join first, then verify membership via REST
1433
+ if (ws && ws.readyState === WebSocket.OPEN) {
1434
+ try { ws.send(JSON.stringify({ type: "join_channel", channel_id: chat_id, agent_id: AGENT_ID })); } catch {}
1435
+ }
1436
+ // Verify by checking membership
1437
+ try {
1438
+ await new Promise(r => setTimeout(r, 500)); // wait for server to process
1439
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1440
+ if (r.ok) {
1441
+ const data = await r.json() as any;
1442
+ const isMember = (data.members || []).some((m: any) => m.agent_id === AGENT_ID);
1443
+ if (isMember) return { content: [{ type: "text", text: `Joined channel ${chat_id.slice(0, 8)}` }] };
1444
+ }
1445
+ return { content: [{ type: "text", text: `Join failed — channel may be private. Ask an admin to invite you.` }] };
1446
+ } catch {
1447
+ return { content: [{ type: "text", text: `Join sent but could not verify membership` }] };
1448
+ }
1449
+ }
1450
+
1451
+ if (name === "leave_channel") {
1452
+ const { chat_id } = args as any;
1453
+ // Prefer REST /leave (authoritative HTTP response confirms eviction);
1454
+ // fall through to WS leave_channel if REST is unreachable so existing
1455
+ // server-side WS handler still fires and updates in-memory state.
1456
+ try {
1457
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/leave`, {
1458
+ method: "POST",
1459
+ headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1460
+ body: "{}",
1461
+ });
1462
+ if (r.ok) {
1463
+ const data = await r.json().catch(() => ({})) as any;
1464
+ if (data.note === "not a member") {
1465
+ return { content: [{ type: "text", text: `Already not a member of ${chat_id.slice(0, 8)}` }] };
1466
+ }
1467
+ return { content: [{ type: "text", text: `Left channel ${chat_id.slice(0, 8)}` }] };
1468
+ }
1469
+ if (r.status === 404) {
1470
+ return { content: [{ type: "text", text: `Channel ${chat_id.slice(0, 8)} not found` }] };
1471
+ }
1472
+ return { content: [{ type: "text", text: `Leave failed with status ${r.status}` }] };
1473
+ } catch (e: any) {
1474
+ // REST unreachable — fall back to WS leave so at least in-memory state updates
1475
+ if (ws && ws.readyState === WebSocket.OPEN) {
1476
+ try { ws.send(JSON.stringify({ type: "leave_channel", channel_id: chat_id, agent_id: AGENT_ID })); } catch {}
1477
+ return { content: [{ type: "text", text: `Leave sent via WS (REST unreachable: ${String(e?.message || e).slice(0, 60)})` }] };
1478
+ }
1479
+ return { content: [{ type: "text", text: `Leave failed — no connectivity` }] };
1480
+ }
1481
+ }
1482
+
1483
+ // ── Hidden Identity (谁是卧底) ────────────────────────────────────
1484
+ // See docs/MCP-HIDDEN-IDENTITY-SCHEMA.md + spec/hidden-identity.md.
1485
+ // Agents playing the game need tool access to join / fetch secret /
1486
+ // vote / advance / inspect state. Without these, the game is driven
1487
+ // only by humans and bots become decorative. These wrap the server
1488
+ // REST endpoints 1:1; the dispatcher at spec/schema §WS broadcasts
1489
+ // tells the agent _when_ to call (via meta on channel notifications).
1490
+
1491
+ if (name === "hidden_identity_join") {
1492
+ const { game_id } = args as any;
1493
+ try {
1494
+ const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/join`, {
1495
+ method: "POST",
1496
+ headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1497
+ body: "{}",
1498
+ });
1499
+ const data = await r.json().catch(() => ({})) as any;
1500
+ if (r.ok) {
1501
+ const count = data?.game?.player_ids?.length ?? data?.game?.players?.length ?? "?";
1502
+ return { content: [{ type: "text", text: `Joined game ${String(game_id).slice(0, 8)} — ${count} players in lobby` }] };
1503
+ }
1504
+ return { content: [{ type: "text", text: `Join failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
1505
+ } catch (e: any) {
1506
+ return { content: [{ type: "text", text: `Join failed: ${String(e?.message || e).slice(0, 80)}` }] };
1507
+ }
1508
+ }
1509
+
1510
+ if (name === "hidden_identity_get_secret") {
1511
+ const { game_id } = args as any;
1512
+ try {
1513
+ const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/secret`, {
1514
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1515
+ });
1516
+ const data = await r.json().catch(() => ({})) as any;
1517
+ if (r.ok) {
1518
+ return { content: [{ type: "text", text: `Your role: ${data.role}. Your word: ${data.word}. Do NOT reveal the word directly in discussion — describe it.` }] };
1519
+ }
1520
+ if (r.status === 403) return { content: [{ type: "text", text: `You are not a player in this game (403)` }] };
1521
+ if (r.status === 404) return { content: [{ type: "text", text: `Game or secret not allocated yet (game may still be in lobby)` }] };
1522
+ return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }] };
1523
+ } catch (e: any) {
1524
+ return { content: [{ type: "text", text: `Secret fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
1525
+ }
1526
+ }
1527
+
1528
+ if (name === "hidden_identity_vote") {
1529
+ const { game_id, target_id, reason } = args as any;
1530
+ try {
1531
+ const body: any = { target_id };
1532
+ if (typeof reason === "string" && reason) body.reason = reason;
1533
+ const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/vote`, {
1534
+ method: "POST",
1535
+ headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1536
+ body: JSON.stringify(body),
1537
+ });
1538
+ const data = await r.json().catch(() => ({})) as any;
1539
+ if (r.ok) {
1540
+ return { content: [{ type: "text", text: `Vote cast against ${String(target_id).slice(0, 12)} in round ${data?.round}` }] };
1541
+ }
1542
+ return { content: [{ type: "text", text: `Vote failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
1543
+ } catch (e: any) {
1544
+ return { content: [{ type: "text", text: `Vote failed: ${String(e?.message || e).slice(0, 80)}` }] };
1545
+ }
1546
+ }
1547
+
1548
+ if (name === "hidden_identity_advance") {
1549
+ const { game_id, to } = args as any;
1550
+ try {
1551
+ const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/advance`, {
1552
+ method: "POST",
1553
+ headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1554
+ body: JSON.stringify({ to }),
1555
+ });
1556
+ const data = await r.json().catch(() => ({})) as any;
1557
+ if (r.ok) {
1558
+ return { content: [{ type: "text", text: `Phase advanced to ${data?.phase || to}, round ${data?.round ?? "?"}` }] };
1559
+ }
1560
+ if (r.status === 409) return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }] };
1561
+ return { content: [{ type: "text", text: `Advance failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
1562
+ } catch (e: any) {
1563
+ return { content: [{ type: "text", text: `Advance failed: ${String(e?.message || e).slice(0, 80)}` }] };
1564
+ }
1565
+ }
1566
+
1567
+ if (name === "hidden_identity_get_state") {
1568
+ const { game_id } = args as any;
1569
+ try {
1570
+ const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}`, {
1571
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1572
+ });
1573
+ const data = await r.json().catch(() => ({})) as any;
1574
+ if (r.ok) {
1575
+ const g = data?.game || {};
1576
+ const players = (g.players || []).map((p: any) => {
1577
+ return `${p.display_name || p.player_id}${p.is_eliminated ? " (out)" : ""}`;
1578
+ }).join(", ");
1579
+ return { content: [{ type: "text", text: `Phase: ${g.phase}, Round: ${g.round}, Winner: ${g.winner_team || "—"}. Players: ${players}` }] };
1580
+ }
1581
+ return { content: [{ type: "text", text: `Game state fetch failed (${r.status})` }] };
1582
+ } catch (e: any) {
1583
+ return { content: [{ type: "text", text: `Game state fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
1584
+ }
1585
+ }
1586
+
1587
+ if (name === "mark_read") {
1588
+ const { chat_id, last_read_id } = args as any;
1589
+ if (ws && ws.readyState === WebSocket.OPEN) {
1590
+ ws.send(JSON.stringify({
1591
+ type: "read_receipt", channel_id: chat_id,
1592
+ sender_id: AGENT_ID, last_read_id, timestamp: new Date().toISOString(),
1593
+ }));
1594
+ return { content: [{ type: "text", text: `Read cursor update dispatched (up to ${last_read_id.slice(0, 8)})` }] };
1595
+ }
1596
+ return { content: [{ type: "text", text: "Not connected" }] };
1597
+ }
1598
+
1599
+ if (name === "whoami") {
1600
+ const wsState = ws?.readyState === WebSocket.OPEN ? "connected" : ws?.readyState === WebSocket.CONNECTING ? "connecting" : "disconnected";
1601
+ return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${profileFile}` }] };
1602
+ }
1603
+
1604
+ if (name === "list_channels") {
1605
+ const { limit = 50 } = args as any;
1606
+ try {
1607
+ const r = await fetch(`${REST_URL}/api/channels/discover?limit=${Math.min(limit, 500)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1608
+ if (r.ok) {
1609
+ const data = await r.json() as any;
1610
+ const channels = (data.channels || []);
1611
+ if (channels.length === 0) return { content: [{ type: "text", text: "No public channels found." }] };
1612
+ const list = channels.map((ch: any) => `• ${ch.name || ch.id} (${ch.id.slice(0, 8)}) — ${ch.member_count || "?"} members${ch.topic ? ` — ${ch.topic.slice(0, 60)}` : ""}`).join("\n");
1613
+ return { content: [{ type: "text", text: `${channels.length} channels:\n${list}` }] };
1614
+ }
1615
+ return { content: [{ type: "text", text: `Failed to list channels (${r.status})` }] };
1616
+ } catch (e) {
1617
+ return { content: [{ type: "text", text: `Error: ${e}` }] };
1618
+ }
1619
+ }
1620
+
1621
+ if (name === "list_members") {
1622
+ const { chat_id } = args as any;
1623
+ try {
1624
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1625
+ if (r.ok) {
1626
+ const data = await r.json() as any;
1627
+ const members = data.members || [];
1628
+ if (members.length === 0) return { content: [{ type: "text", text: "No members found." }] };
1629
+ const list = members.map((m: any) => `• ${m.display_name || m.agent_id} (${m.agent_id.slice(0, 12)})${m.role ? ` [${m.role}]` : ""}`).join("\n");
1630
+ return { content: [{ type: "text", text: `${members.length} members in ${chat_id.slice(0, 8)}:\n${list}` }] };
1631
+ }
1632
+ return { content: [{ type: "text", text: `Failed to list members (${r.status})` }] };
1633
+ } catch (e) {
1634
+ return { content: [{ type: "text", text: `Error: ${e}` }] };
1635
+ }
1636
+ }
1637
+
1638
+ if (name === "get_history") {
1639
+ const { chat_id, limit = 20 } = args as any;
1640
+ try {
1641
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages?limit=${Math.min(limit, 100)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1642
+ if (r.ok) {
1643
+ const data = await r.json() as any;
1644
+ const msgs = (data.messages || []).filter((m: any) => m.content !== "__typing__");
1645
+ if (msgs.length === 0) return { content: [{ type: "text", text: "No messages in this channel." }] };
1646
+ const list = msgs.map((m: any) => {
1647
+ const time = m.timestamp ? new Date(m.timestamp).toLocaleString() : "?";
1648
+ return `[${time}] ${m.sender_id?.slice(0, 12)}: ${m.content?.slice(0, 200)}`;
1649
+ }).join("\n");
1650
+ return { content: [{ type: "text", text: `${msgs.length} messages:\n${list}` }] };
1651
+ }
1652
+ return { content: [{ type: "text", text: `Failed to get history (${r.status})` }] };
1653
+ } catch (e) {
1654
+ return { content: [{ type: "text", text: `Error: ${e}` }] };
1655
+ }
1656
+ }
1657
+
1658
+ if (name === "switch_profile") {
1659
+ const { profile_name } = args as any;
1660
+ // List available profiles
1661
+ const { readdirSync } = require("fs");
1662
+ let files: string[] = [];
1663
+ try { files = readdirSync(configDir).filter((f: string) => f.endsWith(".json")); } catch {}
1664
+ const available = files.map((f: string) => f.replace(".json", ""));
1665
+
1666
+ if (!profile_name) {
1667
+ const current = AGENT_ID;
1668
+ const list = available.map(p => `${p === current ? "→ " : " "}${p}`).join("\n");
1669
+ return { content: [{ type: "text", text: `Current: ${current}\nAvailable profiles:\n${list}` }] };
1670
+ }
1671
+
1672
+ // Find and load the profile
1673
+ const targetFile = nameToPath(profile_name);
1674
+ if (!existsSync(targetFile)) {
1675
+ return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }] };
1676
+ }
1677
+
1678
+ const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
1679
+
1680
+ // Stop heartbeat first to prevent race with old connection
1681
+ heartbeat.stop();
1682
+
1683
+ // Close old connection, disable its reconnect handler
1684
+ if (ws) {
1685
+ ws.onclose = null;
1686
+ try { ws.close(); } catch {}
1687
+ ws = null;
1688
+ }
1689
+ sessionId = null;
1690
+
1691
+ // Update identity
1692
+ AGENT_ID = newProfile.agent_id;
1693
+ TOKEN = newProfile.token || "dev-token";
1694
+ CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
1695
+ profile = newProfile;
1696
+
1697
+ // Restart heartbeat and connect with new identity
1698
+ wsReconnectAttempt = 0;
1699
+ heartbeat.start();
1700
+ connectWS();
1701
+
1702
+ return { content: [{ type: "text", text: `Switched to profile "${profile_name}" (${AGENT_ID}). Reconnecting...` }] };
1703
+ }
1704
+
1705
+ if (name === "list_channel_docs") {
1706
+ const { chat_id, level } = args as { chat_id: string; level?: number };
1707
+ const qs = new URLSearchParams();
1708
+ if (typeof level === "number") qs.set("level", String(level));
1709
+ const url = `${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs${qs.toString() ? `?${qs}` : ""}`;
1710
+ try {
1711
+ const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1712
+ const text = await r.text();
1713
+ if (!r.ok) {
1714
+ return { content: [{ type: "text", text: `list_channel_docs failed (${r.status}): ${text.slice(0, 200)}` }] };
1715
+ }
1716
+ return { content: [{ type: "text", text }] };
1717
+ } catch (e: any) {
1718
+ return { content: [{ type: "text", text: `list_channel_docs network error: ${String(e?.message || e).slice(0, 120)}` }] };
1719
+ }
1720
+ }
1721
+
1722
+ if (name === "get_channel_doc") {
1723
+ const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
1724
+ try {
1725
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1726
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1727
+ });
1728
+ const text = await r.text();
1729
+ if (!r.ok) {
1730
+ return { content: [{ type: "text", text: `get_channel_doc failed (${r.status}): ${text.slice(0, 200)}` }] };
1731
+ }
1732
+ return { content: [{ type: "text", text }] };
1733
+ } catch (e: any) {
1734
+ return { content: [{ type: "text", text: `get_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }] };
1735
+ }
1736
+ }
1737
+
1738
+ if (name === "upsert_channel_doc") {
1739
+ const { chat_id, doc_id, title, kind, level, body_markdown, expected_version } = args as {
1740
+ chat_id: string;
1741
+ doc_id: string;
1742
+ title: string;
1743
+ kind: string;
1744
+ level: number;
1745
+ body_markdown: string;
1746
+ expected_version: number;
1747
+ };
1748
+ try {
1749
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1750
+ method: "PUT",
1751
+ headers: {
1752
+ "Content-Type": "application/json",
1753
+ "Authorization": `Bearer ${TOKEN}`,
1754
+ "If-Match": String(expected_version),
1755
+ },
1756
+ body: JSON.stringify({ title, kind, level, body_markdown }),
1757
+ });
1758
+ const text = await r.text();
1759
+ if (!r.ok) {
1760
+ return { content: [{ type: "text", text: `upsert_channel_doc failed (${r.status}): ${text.slice(0, 240)}` }] };
1761
+ }
1762
+ return { content: [{ type: "text", text }] };
1763
+ } catch (e: any) {
1764
+ return { content: [{ type: "text", text: `upsert_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }] };
1765
+ }
1766
+ }
1767
+
1768
+ if (name === "list_channel_doc_revisions") {
1769
+ const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
1770
+ try {
1771
+ const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}/revisions`, {
1772
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1773
+ });
1774
+ const text = await r.text();
1775
+ if (!r.ok) {
1776
+ return { content: [{ type: "text", text: `list_channel_doc_revisions failed (${r.status}): ${text.slice(0, 200)}` }] };
1777
+ }
1778
+ return { content: [{ type: "text", text }] };
1779
+ } catch (e: any) {
1780
+ return { content: [{ type: "text", text: `list_channel_doc_revisions network error: ${String(e?.message || e).slice(0, 120)}` }] };
1781
+ }
1782
+ }
1783
+
1784
+ // OKR v0.1 tools — dogfood the OKR system without dropping to curl.
1785
+ // Maps 1:1 to the server-side routes shipped in commit 5229aab
1786
+ // (projects/AgentChat/Server/src/okr.ts + index.ts dispatch block).
1787
+ if (name === "okr_list") {
1788
+ const { owner, status, horizon, include_archived, view, task_id } = args as { owner?: string; status?: string; horizon?: string; include_archived?: boolean; view?: string; task_id?: string };
1789
+ const qs = new URLSearchParams();
1790
+ if (owner) qs.set("owner", owner);
1791
+ if (status) qs.set("status", status);
1792
+ if (horizon) qs.set("horizon", horizon);
1793
+ if (include_archived) qs.set("include_archived", "true");
1794
+ if (view) qs.set("view", view);
1795
+ if (task_id) qs.set("task_id", task_id);
1796
+ const url = `${REST_URL}/api/okr/objectives${qs.toString() ? "?" + qs.toString() : ""}`;
1797
+ try {
1798
+ const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1799
+ if (!r.ok) {
1800
+ const err = await r.text();
1801
+ return { content: [{ type: "text", text: `okr_list failed (${r.status}): ${err.slice(0, 120)}` }] };
1802
+ }
1803
+ const data = await r.json() as any;
1804
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1805
+ } catch (e: any) {
1806
+ return { content: [{ type: "text", text: `okr_list network error: ${String(e?.message || e).slice(0, 120)}` }] };
1807
+ }
1808
+ }
1809
+
1810
+ if (name === "okr_create_objective") {
1811
+ const { title, horizon, owner, parent_id, due } = args as {
1812
+ title: string; horizon: string; owner?: string; parent_id?: string; due?: string;
1813
+ };
1814
+ const body: Record<string, unknown> = { title, horizon };
1815
+ if (owner) body.owner = owner;
1816
+ if (parent_id) body.parent_id = parent_id;
1817
+ if (due) body.due = due;
1818
+ try {
1819
+ const r = await fetch(`${REST_URL}/api/okr/objectives`, {
1820
+ method: "POST",
1821
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1822
+ body: JSON.stringify(body),
1823
+ });
1824
+ const text = await r.text();
1825
+ if (!r.ok) {
1826
+ return { content: [{ type: "text", text: `okr_create_objective failed (${r.status}): ${text.slice(0, 160)}` }] };
1827
+ }
1828
+ return { content: [{ type: "text", text: `Created: ${text}` }] };
1829
+ } catch (e: any) {
1830
+ return { content: [{ type: "text", text: `okr_create_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
1831
+ }
1832
+ }
1833
+
1834
+ if (name === "okr_add_task") {
1835
+ const { objective_id, title, assignee, contributes_to, depends_on, due } = args as {
1836
+ objective_id: string; title: string; assignee: string; contributes_to?: string[]; depends_on?: string[]; due?: string;
1837
+ };
1838
+ const body: Record<string, unknown> = { title, assignee };
1839
+ if (Array.isArray(contributes_to) && contributes_to.length > 0) body.contributes_to = contributes_to;
1840
+ if (Array.isArray(depends_on) && depends_on.length > 0) body.depends_on = depends_on;
1841
+ if (due) body.due = due;
1842
+ try {
1843
+ const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/tasks`, {
1844
+ method: "POST",
1845
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1846
+ body: JSON.stringify(body),
1847
+ });
1848
+ const text = await r.text();
1849
+ if (!r.ok) {
1850
+ return { content: [{ type: "text", text: `okr_add_task failed (${r.status}): ${text.slice(0, 160)}` }] };
1851
+ }
1852
+ return { content: [{ type: "text", text: `Added: ${text}` }] };
1853
+ } catch (e: any) {
1854
+ return { content: [{ type: "text", text: `okr_add_task network error: ${String(e?.message || e).slice(0, 120)}` }] };
1855
+ }
1856
+ }
1857
+
1858
+ if (name === "okr_update_task") {
1859
+ const { task_id, status, assignee, blocked_reason, blocker_agent, depends_on, due } = args as { task_id: string; status?: string; assignee?: string; blocked_reason?: string; blocker_agent?: string; depends_on?: string[]; due?: string };
1860
+ const patch: Record<string, unknown> = {};
1861
+ if (status) patch.status = status;
1862
+ if (assignee) patch.assignee = assignee;
1863
+ if (blocked_reason !== undefined) patch.blocked_reason = blocked_reason;
1864
+ if (blocker_agent !== undefined) patch.blocker_agent = blocker_agent;
1865
+ if (Array.isArray(depends_on)) patch.depends_on = depends_on;
1866
+ if (due) patch.due = due;
1867
+ try {
1868
+ const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}`, {
1869
+ method: "PATCH",
1870
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1871
+ body: JSON.stringify(patch),
1872
+ });
1873
+ const text = await r.text();
1874
+ if (!r.ok) {
1875
+ return { content: [{ type: "text", text: `okr_update_task failed (${r.status}): ${text.slice(0, 160)}` }] };
1876
+ }
1877
+ return { content: [{ type: "text", text: `Updated: ${text}` }] };
1878
+ } catch (e: any) {
1879
+ return { content: [{ type: "text", text: `okr_update_task network error: ${String(e?.message || e).slice(0, 120)}` }] };
1880
+ }
1881
+ }
1882
+
1883
+ if (name === "okr_task_blockers" || name === "okr_task_blocks") {
1884
+ const { task_id } = args as { task_id: string };
1885
+ const path = name === "okr_task_blockers" ? "blockers" : "blocks";
1886
+ try {
1887
+ const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/${path}`, {
1888
+ headers: { "Authorization": `Bearer ${TOKEN}` },
1889
+ });
1890
+ const text = await r.text();
1891
+ if (!r.ok) {
1892
+ return { content: [{ type: "text", text: `${name} failed (${r.status}): ${text.slice(0, 160)}` }] };
1893
+ }
1894
+ return { content: [{ type: "text", text }] };
1895
+ } catch (e: any) {
1896
+ return { content: [{ type: "text", text: `${name} network error: ${String(e?.message || e).slice(0, 120)}` }] };
1897
+ }
1898
+ }
1899
+
1900
+ if (name === "okr_open_thread") {
1901
+ const { target_type, target_id } = args as { target_type: string; target_id: string };
1902
+ try {
1903
+ const r = await fetch(`${REST_URL}/api/okr/threads`, {
1904
+ method: "POST",
1905
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1906
+ body: JSON.stringify({ target_type, target_id }),
1907
+ });
1908
+ const text = await r.text();
1909
+ if (!r.ok) {
1910
+ return { content: [{ type: "text", text: `okr_open_thread failed (${r.status}): ${text.slice(0, 160)}` }] };
1911
+ }
1912
+ return { content: [{ type: "text", text }] };
1913
+ } catch (e: any) {
1914
+ return { content: [{ type: "text", text: `okr_open_thread network error: ${String(e?.message || e).slice(0, 120)}` }] };
1915
+ }
1916
+ }
1917
+
1918
+ if (name === "okr_add_kr") {
1919
+ const { objective_id, title, metric_type, current, target, risk_level } = args as {
1920
+ objective_id: string; title: string; metric_type: string; current?: number; target: number; risk_level?: string;
1921
+ };
1922
+ const body: Record<string, unknown> = { title, metric_type, target };
1923
+ if (typeof current === "number") body.current = current;
1924
+ if (risk_level) body.risk_level = risk_level;
1925
+ try {
1926
+ const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/krs`, {
1927
+ method: "POST",
1928
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1929
+ body: JSON.stringify(body),
1930
+ });
1931
+ const text = await r.text();
1932
+ if (!r.ok) {
1933
+ return { content: [{ type: "text", text: `okr_add_kr failed (${r.status}): ${text.slice(0, 160)}` }] };
1934
+ }
1935
+ return { content: [{ type: "text", text: `Added: ${text}` }] };
1936
+ } catch (e: any) {
1937
+ return { content: [{ type: "text", text: `okr_add_kr network error: ${String(e?.message || e).slice(0, 120)}` }] };
1938
+ }
1939
+ }
1940
+
1941
+ if (name === "archive_objective") {
1942
+ const { objective_id, completion_summary } = args as { objective_id: string; completion_summary?: string };
1943
+ try {
1944
+ const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/archive`, {
1945
+ method: "POST",
1946
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1947
+ body: JSON.stringify(completion_summary !== undefined ? { completion_summary } : {}),
1948
+ });
1949
+ const text = await r.text();
1950
+ if (!r.ok) {
1951
+ return { content: [{ type: "text", text: `archive_objective failed (${r.status}): ${text.slice(0, 200)}` }] };
1952
+ }
1953
+ return { content: [{ type: "text", text: `Archived: ${text}` }] };
1954
+ } catch (e: any) {
1955
+ return { content: [{ type: "text", text: `archive_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
1956
+ }
1957
+ }
1958
+
1959
+ if (name === "unarchive_objective") {
1960
+ const { objective_id } = args as { objective_id: string };
1961
+ try {
1962
+ const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/unarchive`, {
1963
+ method: "POST",
1964
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1965
+ });
1966
+ const text = await r.text();
1967
+ if (!r.ok) {
1968
+ return { content: [{ type: "text", text: `unarchive_objective failed (${r.status}): ${text.slice(0, 200)}` }] };
1969
+ }
1970
+ return { content: [{ type: "text", text: `Unarchived: ${text}` }] };
1971
+ } catch (e: any) {
1972
+ return { content: [{ type: "text", text: `unarchive_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
1973
+ }
1974
+ }
1975
+
1976
+ if (name === "okr_set_kr_progress") {
1977
+ const { kr_id, current, risk_level } = args as { kr_id: string; current: number; risk_level?: string };
1978
+ const body: Record<string, unknown> = { current };
1979
+ if (risk_level) body.risk_level = risk_level;
1980
+ try {
1981
+ const r = await fetch(`${REST_URL}/api/okr/krs/${encodeURIComponent(kr_id)}/progress`, {
1982
+ method: "PATCH",
1983
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1984
+ body: JSON.stringify(body),
1985
+ });
1986
+ const text = await r.text();
1987
+ if (!r.ok) {
1988
+ return { content: [{ type: "text", text: `okr_set_kr_progress failed (${r.status}): ${text.slice(0, 160)}` }] };
1989
+ }
1990
+ return { content: [{ type: "text", text: `Updated: ${text}` }] };
1991
+ } catch (e: any) {
1992
+ return { content: [{ type: "text", text: `okr_set_kr_progress network error: ${String(e?.message || e).slice(0, 120)}` }] };
1993
+ }
1994
+ }
1995
+
1996
+ if (name === "okr_add_task_comment") {
1997
+ const { task_id, text: rawText } = args as { task_id: string; text: string };
1998
+ const text = redactSecrets(rawText);
1999
+ try {
2000
+ const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/comments`, {
2001
+ method: "POST",
2002
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2003
+ body: JSON.stringify({ text }),
2004
+ });
2005
+ const body = await r.text();
2006
+ if (!r.ok) {
2007
+ return { content: [{ type: "text", text: `okr_add_task_comment failed (${r.status}): ${body.slice(0, 160)}` }] };
2008
+ }
2009
+ return { content: [{ type: "text", text: `Commented: ${body}` }] };
2010
+ } catch (e: any) {
2011
+ return { content: [{ type: "text", text: `okr_add_task_comment network error: ${String(e?.message || e).slice(0, 120)}` }] };
2012
+ }
2013
+ }
2014
+
2015
+ if (name === "okr_set_links") {
2016
+ const { target_type, target_id, narrative, narrative_path, linked_docs, linked_channel_docs } = args as {
2017
+ target_type: "objective" | "kr" | "task";
2018
+ target_id: string;
2019
+ narrative?: string;
2020
+ narrative_path?: string;
2021
+ linked_docs?: string[];
2022
+ linked_channel_docs?: Array<{ channel_id: string; doc_id: string }>;
2023
+ };
2024
+ const body: Record<string, unknown> = {};
2025
+ if (narrative !== undefined) body.narrative = narrative;
2026
+ if (narrative_path !== undefined) body.narrative_path = narrative_path;
2027
+ if (linked_docs !== undefined) body.linked_docs = linked_docs;
2028
+ if (linked_channel_docs !== undefined) body.linked_channel_docs = linked_channel_docs;
2029
+ try {
2030
+ const r = await fetch(`${REST_URL}/api/okr/links/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`, {
2031
+ method: "PATCH",
2032
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2033
+ body: JSON.stringify(body),
2034
+ });
2035
+ const text = await r.text();
2036
+ if (!r.ok) {
2037
+ return { content: [{ type: "text", text: `okr_set_links failed (${r.status}): ${text.slice(0, 200)}` }] };
2038
+ }
2039
+ return { content: [{ type: "text", text: `Updated: ${text}` }] };
2040
+ } catch (e: any) {
2041
+ return { content: [{ type: "text", text: `okr_set_links network error: ${String(e?.message || e).slice(0, 120)}` }] };
2042
+ }
2043
+ }
2044
+
2045
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
2046
+ });
2047
+
2048
+ // --- WebSocket Connection ---
2049
+
2050
+ // Track last @mention timestamp per channel (for context windowing)
2051
+ // Persisted to disk so reconnect/restart doesn't lose state
2052
+ const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
2053
+ function loadMentionTimestamps(): Map<string, string> {
2054
+ try {
2055
+ const raw = require("fs").readFileSync(mentionTsFile, "utf-8");
2056
+ return new Map(Object.entries(JSON.parse(raw)));
2057
+ } catch { return new Map(); }
2058
+ }
2059
+ function saveMentionTimestamps(m: Map<string, string>) {
2060
+ try {
2061
+ require("fs").writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2062
+ } catch {}
2063
+ }
2064
+ const lastMentionTimestamp = loadMentionTimestamps();
2065
+
2066
+ // Task #119: track the last-seen message timestamp per channel so a
2067
+ // reconnect can backfill messages the WS missed. Separate from
2068
+ // mention-ts because mention-ts only advances on mentions — we need
2069
+ // all messages (including non-mention ones) to compute the correct
2070
+ // backfill cursor. Persisted to disk: plugin restart resumes from
2071
+ // where it left off.
2072
+ //
2073
+ // Bug this fixes: even without a visible WS disconnect, Redis
2074
+ // ac:ch:* subscribe can briefly miss a broadcast (subscriber rebuild
2075
+ // window, transient network hiccup). Claude Code log 2026-04-20
2076
+ // 12:49 showed boss's @-mention never firing notifications/claude/
2077
+ // channel for claude-code-live even though my gcloud had no
2078
+ // disconnect event — so the "reconnect" trigger alone doesn't
2079
+ // cover this class. Backfill runs on every auth_ok whether first
2080
+ // connect or reconnect; if we missed anything, we find it.
2081
+ const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
2082
+ function loadLastSeenMessageTs(): Map<string, string> {
2083
+ try {
2084
+ const raw = require("fs").readFileSync(lastSeenMessageTsFile, "utf-8");
2085
+ return new Map(Object.entries(JSON.parse(raw)));
2086
+ } catch { return new Map(); }
2087
+ }
2088
+ function saveLastSeenMessageTs(m: Map<string, string>) {
2089
+ try {
2090
+ require("fs").writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2091
+ } catch {}
2092
+ }
2093
+ const lastSeenMessageTs = loadLastSeenMessageTs();
2094
+
2095
+ // Local ingress dedup for live WS + reconnect backfill races.
2096
+ //
2097
+ // `lastSeenMessageTs` is a cursor, not message identity. A reconnect can
2098
+ // legitimately receive the same persisted message once via live WS and once
2099
+ // via REST backfill; timestamp guards alone would either fail to drop that
2100
+ // duplicate or drop older out-of-order messages that were never processed.
2101
+ const deliveredMessageIds = new Set<string>();
2102
+ const MAX_DELIVERED_MESSAGE_IDS = 5000;
2103
+ function deliverySource(data: any): string {
2104
+ return typeof data?.__source === "string" ? data.__source : "live";
2105
+ }
2106
+ function messageDedupKey(data: any): string | null {
2107
+ if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string") return null;
2108
+ return `${data.channel_id}:${data.id}`;
2109
+ }
2110
+ function recordOrSkipDeliveredMessage(data: any): boolean {
2111
+ const key = messageDedupKey(data);
2112
+ if (!key) return false;
2113
+ if (deliveredMessageIds.has(key)) {
2114
+ process.stderr.write(
2115
+ `[agentchat] Duplicate message skipped source=${deliverySource(data)} chat=${String(data.channel_id).slice(0, 12)} id=${String(data.id).slice(0, 12)}\n`,
2116
+ );
2117
+ return true;
2118
+ }
2119
+ deliveredMessageIds.add(key);
2120
+ if (deliveredMessageIds.size > MAX_DELIVERED_MESSAGE_IDS) {
2121
+ const arr = [...deliveredMessageIds];
2122
+ deliveredMessageIds.clear();
2123
+ for (const item of arr.slice(1000)) deliveredMessageIds.add(item);
2124
+ }
2125
+ return false;
2126
+ }
2127
+ // Channels we believe we're a member of. Populated from
2128
+ // `channel_created` events on auth_ok. Used as the backfill target set.
2129
+ const knownChannels = new Set<string>();
2130
+ // Task #119: exposed handler reference so backfillAllChannels (module-
2131
+ // scope) can reuse connectWS's handleWSMessage closure without
2132
+ // duplicating the mention/notification gate logic.
2133
+ let currentHandleWSMessage: ((data: any) => Promise<void>) | null = null;
2134
+ let wsReconnectAttempt = 0;
2135
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2136
+ /** Flipped true when the server sends shard_moved (planned drain).
2137
+ * ws.onclose checks + clears it so the close is treated as a clean
2138
+ * hop, not a failure that advances the exponential backoff. */
2139
+ let isPlannedReconnect = false;
2140
+
2141
+ function scheduleReconnect(delayMs: number) {
2142
+ if (reconnectTimer) clearTimeout(reconnectTimer);
2143
+ reconnectTimer = setTimeout(() => {
2144
+ reconnectTimer = null;
2145
+ connectWS();
2146
+ }, delayMs);
2147
+ }
2148
+
2149
+ /**
2150
+ * Task #119: fetch any channel messages that arrived while the WS
2151
+ * was unreliable or down. Invoked 2s after every auth_ok — covers
2152
+ * both cold start (empty cursors = no backfill) and reconnect
2153
+ * (cursor points to last-seen, REST returns the gap).
2154
+ *
2155
+ * Iterates channels we believe we're in (populated from the
2156
+ * channel_created events that follow auth_ok). For each, GETs
2157
+ * /api/channels/:id/messages?after=<lastSeenTs> and re-injects
2158
+ * every message through handleWSMessage — same code path as live
2159
+ * delivery, so @mention detection + notification emission go
2160
+ * through the same gate. Self-messages are filtered by the handler
2161
+ * (sender_id !== AGENT_ID).
2162
+ *
2163
+ * Deduplication is by message id in handleWSMessage. Timestamp cursors
2164
+ * decide what backfill requests should ask for, but they are not a safe
2165
+ * identity check under live/backfill races or out-of-order delivery.
2166
+ */
2167
+ async function backfillAllChannels(): Promise<void> {
2168
+ if (knownChannels.size === 0) return;
2169
+ for (const channelId of knownChannels) {
2170
+ try {
2171
+ const after = lastSeenMessageTs.get(channelId);
2172
+ const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
2173
+ const url = `${REST_URL}/api/channels/${encodeURIComponent(channelId)}/messages${params}`;
2174
+ const res = await fetch(url, {
2175
+ headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
2176
+ });
2177
+ if (!res.ok) continue;
2178
+ const data = await res.json() as { messages?: any[] };
2179
+ const msgs = data.messages || [];
2180
+ // Filter out self-messages upfront (faster than re-entering the
2181
+ // handler just to bail) and typing placeholders (plugin ignores
2182
+ // them anyway).
2183
+ const replay = msgs.filter((m: any) =>
2184
+ m && m.sender_id !== AGENT_ID && m.content !== "__typing__");
2185
+ if (replay.length === 0) continue;
2186
+ process.stderr.write(`[agentchat] Backfill ${channelId.slice(0, 12)}: ${replay.length} missed msg(s)\n`);
2187
+ // Sort ascending so replay order matches live chronology —
2188
+ // lastSeenMessageTs advances monotonically.
2189
+ replay.sort((a: any, b: any) => String(a.timestamp).localeCompare(String(b.timestamp)));
2190
+ for (const m of replay) {
2191
+ try {
2192
+ // Wrap as a `message` envelope to match ws.onmessage shape.
2193
+ if (currentHandleWSMessage) {
2194
+ await currentHandleWSMessage({ ...m, type: "message", __source: "backfill" });
2195
+ }
2196
+ } catch (e) {
2197
+ process.stderr.write(`[agentchat] Backfill replay error: ${e}\n`);
2198
+ }
2199
+ }
2200
+ } catch (e) {
2201
+ process.stderr.write(`[agentchat] Backfill fetch failed for ${channelId.slice(0, 12)}: ${e}\n`);
2202
+ }
2203
+ }
2204
+ }
2205
+
2206
+ function connectWS() {
2207
+ try {
2208
+ ws = new WebSocket(WS_URL);
2209
+ } catch (e) {
2210
+ process.stderr.write(`[agentchat] WebSocket constructor failed: ${e}, retrying in 5s\n`);
2211
+ setTimeout(connectWS, 5000);
2212
+ return;
2213
+ }
2214
+
2215
+ ws.onopen = () => {
2216
+ try {
2217
+ ws!.send(JSON.stringify({
2218
+ type: "auth",
2219
+ agent_id: AGENT_ID,
2220
+ token: TOKEN,
2221
+ capabilities: CAPABILITIES,
2222
+ }));
2223
+ } catch (e) {
2224
+ process.stderr.write(`[agentchat] Auth send failed: ${e}\n`);
2225
+ }
2226
+ };
2227
+
2228
+ ws.onmessage = async (event) => {
2229
+ let data: any;
2230
+ try { data = JSON.parse(String(event.data)); } catch { return; }
2231
+ if (data && typeof data === "object" && !data.__source) data.__source = "live";
2232
+ try { await handleWSMessage(data); } catch (e) {
2233
+ process.stderr.write(`[agentchat] Message handler error: ${e}\n`);
2234
+ }
2235
+ };
2236
+
2237
+ currentHandleWSMessage = handleWSMessage;
2238
+ async function handleWSMessage(data: any) {
2239
+
2240
+ if (data.type === "pong") {
2241
+ heartbeat.receivedPong();
2242
+ return;
2243
+ }
2244
+
2245
+ if (data.type === "auth_ok") {
2246
+ sessionId = data.session_id;
2247
+ wsReconnectAttempt = 0; // reset backoff on successful auth
2248
+ heartbeat.receivedPong(); // treat auth_ok as alive signal
2249
+ process.stderr.write(`[agentchat] Connected as ${AGENT_ID}\n`);
2250
+ // Task #119: fire a backfill 2s after auth_ok to pick up any
2251
+ // messages that arrived while the WS was down or that the Redis
2252
+ // ac:ch:* subscribe happened to miss. Delay gives the server
2253
+ // time to emit channel_created for each joined channel so
2254
+ // knownChannels is populated. backfillAllChannels does the
2255
+ // per-channel REST fetch and re-injects each missed message
2256
+ // through handleWSMessage so @mention detection + notification
2257
+ // path is identical to live delivery — no divergent code paths.
2258
+ setTimeout(() => { void backfillAllChannels(); }, 2000);
2259
+ } else if (data.type === "message" && data.sender_id !== AGENT_ID) {
2260
+ // 跳过 typing 状态消息
2261
+ if (data.content === "__typing__") return;
2262
+
2263
+ if (recordOrSkipDeliveredMessage(data)) return;
2264
+
2265
+ // Task #119: record the timestamp so a future auth_ok backfill
2266
+ // knows where to resume. Only advance forward (defensive against
2267
+ // out-of-order delivery from Redis subscribe vs REST backfill
2268
+ // replay). Persist periodically — not every message to avoid
2269
+ // disk thrash, but at least on every received chat message since
2270
+ // this file is tiny (one row per channel).
2271
+ if (typeof data.channel_id === "string" && typeof data.timestamp === "string") {
2272
+ const prev = lastSeenMessageTs.get(data.channel_id) || "";
2273
+ if (data.timestamp > prev) {
2274
+ lastSeenMessageTs.set(data.channel_id, data.timestamp);
2275
+ saveLastSeenMessageTs(lastSeenMessageTs);
2276
+ }
2277
+ }
2278
+
2279
+ const isDM = data.channel_id?.startsWith("dm-");
2280
+ // Match both `@<agentId>` and `@<displayName>(<agentId>)` formats.
2281
+ // v0.6.1 used a loose `content.includes("(" + AGENT_ID + ")")` for the
2282
+ // second case which fired on ANY text containing `(<agentId>)` —
2283
+ // including system messages like "User joined: name (acc_xyz)" or
2284
+ // moderation logs. Boss msg:fc8b9b1a — codex agent received messages
2285
+ // it wasn't @-mentioned in, ate context window. Tighten the second
2286
+ // clause to require an `@<displayName>` immediately before `(<id>)`.
2287
+ const idEsc = (AGENT_ID || "").replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2288
+ const displayMentionRe = idEsc ? new RegExp(`@[^(\\n]+\\(${idEsc}\\)`) : null;
2289
+ const isMentioned = !!(
2290
+ data.content?.includes(`@${AGENT_ID}`) ||
2291
+ (displayMentionRe && displayMentionRe.test(data.content || ""))
2292
+ );
2293
+
2294
+ if (isDM || isMentioned) {
2295
+ // DM or @mention → respond
2296
+ // 立即发送 typing ACK
2297
+ try {
2298
+ if (ws && ws.readyState === WebSocket.OPEN) {
2299
+ ws.send(JSON.stringify({
2300
+ type: "message", id: crypto.randomUUID(),
2301
+ channel_id: data.channel_id, sender_id: AGENT_ID,
2302
+ sender_type: "agent", content: "__typing__",
2303
+ content_type: "text", timestamp: new Date().toISOString(),
2304
+ }));
2305
+ }
2306
+ } catch {}
2307
+
2308
+ // For @mention in channels, fetch context since last mention
2309
+ let contextPrefix = "";
2310
+ if (!isDM && isMentioned) {
2311
+ try {
2312
+ const lastTs = lastMentionTimestamp.get(data.channel_id) || "";
2313
+ // Cap the request size: 50 messages max, plus client-side byte
2314
+ // and per-message-content trimming below. Boss msg:fc8b9b1a —
2315
+ // long-silent agents would pull a 200-message backlog on first
2316
+ // @mention, blowing up small-context models. 50 + 15KB +
2317
+ // per-msg 2KB matches "recent conversation" without overshoot.
2318
+ const params = `limit=50${lastTs ? '&after=' + encodeURIComponent(lastTs) : ''}`;
2319
+ const historyUrl = `${REST_URL}/api/channels/${encodeURIComponent(data.channel_id)}/messages?${params}`;
2320
+ // Channel reads are auth-gated (login for public channels,
2321
+ // membership for private). Without the Bearer header the
2322
+ // MCP agent would get 401/403 and answer the @mention
2323
+ // without any conversation context.
2324
+ const historyRes = await fetch(historyUrl, {
2325
+ headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
2326
+ });
2327
+ if (historyRes.ok) {
2328
+ const historyData = await historyRes.json() as any;
2329
+ let msgs = (historyData.messages || [])
2330
+ .filter((m: any) => m.id !== data.id && m.content !== "__typing__");
2331
+ // Cumulative byte cap (newest-first walk so we keep the most
2332
+ // recent messages when total exceeds the budget). Per-message
2333
+ // content also clipped to 2KB to defang occasional copy-paste
2334
+ // walls of text — a single mega-message no longer eats the
2335
+ // whole budget alone.
2336
+ let totalBytes = 0;
2337
+ const maxBytes = 15_000;
2338
+ const maxPerMsg = 2_000;
2339
+ const trimmed: any[] = [];
2340
+ for (let i = msgs.length - 1; i >= 0; i--) {
2341
+ const raw = (msgs[i].content || "");
2342
+ const clipped = raw.length > maxPerMsg
2343
+ ? raw.slice(0, maxPerMsg) + " …[truncated]"
2344
+ : raw;
2345
+ const size = clipped.length;
2346
+ if (totalBytes + size > maxBytes) break;
2347
+ totalBytes += size;
2348
+ trimmed.unshift({ ...msgs[i], content: clipped });
2349
+ }
2350
+ const truncatedMsgs = trimmed.length < msgs.length;
2351
+ if (trimmed.length > 0) {
2352
+ const context = trimmed
2353
+ .map((m: any) => `${m.sender_id}: ${m.content}`)
2354
+ .join("\n");
2355
+ const note = truncatedMsgs
2356
+ ? `[频道上下文 - 最近 ${trimmed.length} 条消息(更早的已截断保护上下文窗口)]`
2357
+ : `[频道上下文 - 自上次 @mention 以来 ${trimmed.length} 条消息]`;
2358
+ contextPrefix = `${note}\n${context}\n\n[你被 @mention 了,请回复]\n`;
2359
+ }
2360
+ }
2361
+ // Record this mention timestamp for next time
2362
+ lastMentionTimestamp.set(data.channel_id, data.timestamp);
2363
+ saveMentionTimestamps(lastMentionTimestamp);
2364
+ } catch (e) {
2365
+ process.stderr.write(`[agentchat] Failed to fetch context: ${e}\n`);
2366
+ }
2367
+ }
2368
+
2369
+ process.stderr.write(`[agentchat] ${isDM ? 'DM' : '@mention'} from ${data.sender_id.slice(0, 8)}: ${data.content.slice(0, 50)}\n`);
2370
+
2371
+ // 推送给 Claude Code
2372
+ try {
2373
+ await server.notification({
2374
+ method: process.env.CLAUDE_CODE_ENTRYPOINT ? "notifications/claude/channel" : "notifications/chat/channel",
2375
+ params: {
2376
+ content: contextPrefix + data.content,
2377
+ meta: {
2378
+ chat_id: data.channel_id,
2379
+ sender_id: data.sender_id,
2380
+ message_id: data.id,
2381
+ },
2382
+ },
2383
+ });
2384
+ process.stderr.write(`[agentchat] Notification pushed to Claude Code\n`);
2385
+ } catch (notifErr) {
2386
+ process.stderr.write(`[agentchat] Notification FAILED: ${notifErr}\n`);
2387
+ }
2388
+ } else {
2389
+ // Channel message without @mention → silent (just log)
2390
+ process.stderr.write(`[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`);
2391
+ }
2392
+ } else if (data.type === "channel_created") {
2393
+ // 自动加入新频道
2394
+ try {
2395
+ ws?.send(JSON.stringify({
2396
+ type: "join_channel",
2397
+ channel_id: data.channel_id,
2398
+ agent_id: AGENT_ID,
2399
+ }));
2400
+ } catch {}
2401
+ process.stderr.write(`[agentchat] Joined channel: ${data.name}\n`);
2402
+ // Task #119: track channel id for reconnect backfill target set.
2403
+ if (typeof data.channel_id === "string") knownChannels.add(data.channel_id);
2404
+ } else if (data.type === "shard_moved") {
2405
+ // Server instance shutting down or channel moved — reconnect
2406
+ // immediately. This is a PLANNED disconnect: the server is
2407
+ // giving us heads-up to move. Mark it so the upcoming
2408
+ // ws.onclose doesn't treat this as a failure that increments
2409
+ // wsReconnectAttempt / stretches the exponential backoff. When
2410
+ // the server does a rolling deploy (several pods closing in
2411
+ // sequence), without this flag each shard_moved would push the
2412
+ // next reconnect 2s, 4s, 6s ... further out even though each
2413
+ // is a clean planned event.
2414
+ process.stderr.write(`[agentchat] Shard moved, reconnecting...\n`);
2415
+ if (data.redirect_url) {
2416
+ const newUrl = data.redirect_url.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
2417
+ process.stderr.write(`[agentchat] Redirecting to: ${newUrl}\n`);
2418
+ // Note: for simplicity we reconnect to original URL and let /api/shard handle routing
2419
+ }
2420
+ isPlannedReconnect = true;
2421
+ try { ws?.close(); } catch {}
2422
+ ws = null;
2423
+ sessionId = null;
2424
+ scheduleReconnect(500);
2425
+ } else if (data.type === "error") {
2426
+ process.stderr.write(`[agentchat] Error: ${data.message}\n`);
2427
+ }
2428
+ }
2429
+
2430
+ ws.onclose = (event) => {
2431
+ sessionId = null;
2432
+ heartbeat.resetReconnecting(); // allow heartbeat to reconnect again if needed
2433
+ // Planned reconnect (server-initiated shard_moved): reset backoff
2434
+ // and reconnect fast, don't count this against exponential delay.
2435
+ if (isPlannedReconnect) {
2436
+ isPlannedReconnect = false;
2437
+ wsReconnectAttempt = 0;
2438
+ // A concurrent scheduleReconnect(500) from the shard_moved
2439
+ // handler has already been queued — don't double-schedule.
2440
+ process.stderr.write(`[agentchat] Planned close (code=${(event as any)?.code ?? "?"}), reconnect in 0.5s\n`);
2441
+ return;
2442
+ }
2443
+ wsReconnectAttempt++;
2444
+ const jitter = Math.random() * 3000; // 0-3s random jitter to avoid thundering herd
2445
+ const delay = Math.min(wsReconnectAttempt * 2, 30) * 1000 + jitter;
2446
+ process.stderr.write(`[agentchat] Disconnected (code=${(event as any)?.code ?? "?"}), reconnecting in ${Math.round(delay/100)/10}s (attempt ${wsReconnectAttempt})...\n`);
2447
+ scheduleReconnect(delay);
2448
+ };
2449
+
2450
+ ws.onerror = (err) => {
2451
+ process.stderr.write(`[agentchat] WebSocket error: ${err}\n`);
2452
+ };
2453
+ }
2454
+
2455
+ // Heartbeat with dead-connection detection (15s ping, 45s timeout for faster recovery)
2456
+ import { HeartbeatMonitor, WS_OPEN, WS_CLOSED, WS_CONNECTING, WS_CLOSING } from "./heartbeat.ts";
2457
+
2458
+ const heartbeat = new HeartbeatMonitor({
2459
+ sendPing: () => {
2460
+ try { ws?.send(JSON.stringify({ type: "ping", timestamp: new Date().toISOString() })); } catch {}
2461
+ },
2462
+ reconnect: () => {
2463
+ process.stderr.write("[agentchat] Heartbeat timeout, forcing reconnect\n");
2464
+ try { ws?.close(); } catch {}
2465
+ ws = null;
2466
+ sessionId = null;
2467
+ wsReconnectAttempt = 0; // reset backoff for heartbeat-triggered reconnect
2468
+ scheduleReconnect(500); // short delay to avoid tight loop
2469
+ },
2470
+ getReadyState: () => ws?.readyState ?? WS_CLOSED,
2471
+ }, 15_000, 45_000, 30_000); // 15s ping, 45s pong timeout, 30s connect timeout
2472
+ heartbeat.start();
2473
+
2474
+ // --- Start ---
2475
+ async function main() {
2476
+ connectWS();
2477
+
2478
+ // Stdio is the only supported transport. The --port HTTP SSE path was
2479
+ // removed in v0.6.7 — OpenClaw users should install the native channel
2480
+ // adapter `openclaw-agentchat` (npm) instead of running this plugin
2481
+ // as an HTTP server.
2482
+ const transport = new StdioServerTransport();
2483
+ await server.connect(transport);
2484
+ process.stderr.write("[agentchat] MCP server started (Stdio)\n");
2485
+ }
2486
+
2487
+ main().catch((e) => {
2488
+ process.stderr.write(`[agentchat] Fatal: ${e}\n`);
2489
+ process.exit(1);
2490
+ });
2491
+
2492
+ // Prevent unhandled errors from crashing the process
2493
+ process.on("uncaughtException", (e) => {
2494
+ process.stderr.write(`[agentchat] Uncaught exception (non-fatal): ${e}\n`);
2495
+ });
2496
+ process.on("unhandledRejection", (e) => {
2497
+ process.stderr.write(`[agentchat] Unhandled rejection (non-fatal): ${e}\n`);
2498
+ });