agentschat-mcp 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -4,6 +4,17 @@
4
4
  * 像 weixin 插件一样:WebSocket 消息 → MCP channel notification → Claude Code 对话
5
5
  */
6
6
 
7
+ // Runtime guard: this plugin ships its TypeScript entrypoint directly and
8
+ // relies on Bun's global WebSocket, so it must run under Bun (launch via
9
+ // `bunx agentschat-mcp`, not npx/node). Fail fast with a clear message rather
10
+ // than a cryptic parse/"WebSocket is not defined" error downstream.
11
+ if (typeof (globalThis as any).Bun === "undefined") {
12
+ process.stderr.write(
13
+ "[agentchat] agentschat-mcp requires the Bun runtime. Install Bun (https://bun.sh) and launch with `bunx agentschat-mcp` (not npx/node).\n",
14
+ );
15
+ process.exit(1);
16
+ }
17
+
7
18
  // ── Proxy bypass ──────────────────────────────────────────────────
8
19
  // MCP subprocess inherits the parent's HTTP_PROXY/HTTPS_PROXY which
9
20
  // are set for Claude API access. But this plugin only talks to
@@ -25,6 +36,12 @@ if (process.env.AGENTCHAT_NO_PROXY === "1") {
25
36
 
26
37
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
27
38
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
39
+ import { redactSecrets } from "./redact.ts";
40
+ import { matchesMention } from "./mentions.ts";
41
+ import { messageDedupKey, MessageDedup } from "./dedup.ts";
42
+ import { computeReconnectDelay } from "./reconnect.ts";
43
+ import { normalizeTimestampForCursor } from "./timestamps.ts";
44
+ import { validateToolArgs } from "./argcheck.ts";
28
45
  import {
29
46
  CallToolRequestSchema,
30
47
  ListToolsRequestSchema,
@@ -150,7 +167,7 @@ if (existsSync(profileFile)) {
150
167
  const caps = ["claude-code", "coding", "chat"];
151
168
  process.stderr.write(`[agentchat] First run — registering with server...\n`);
152
169
  try {
153
- const regRes = await fetch(`${REST_URL}/api/account/register`, {
170
+ const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
154
171
  method: "POST",
155
172
  headers: { "Content-Type": "application/json" },
156
173
  body: JSON.stringify({ name: displayName, type: "agent", capabilities: caps, source: "mcp" }),
@@ -184,7 +201,7 @@ if (existsSync(profileFile)) {
184
201
  if (profile.token === "dev-token") {
185
202
  process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
186
203
  try {
187
- const regRes = await fetch(`${REST_URL}/api/account/register`, {
204
+ const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
188
205
  method: "POST",
189
206
  headers: { "Content-Type": "application/json" },
190
207
  body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
@@ -197,7 +214,7 @@ if (profile.token === "dev-token") {
197
214
  process.stderr.write(`[agentchat] Migrated! New key saved. ID: ${data.id}\n`);
198
215
  } else {
199
216
  // ID conflict (409) — old UUID taken. Register with auto-generated id instead.
200
- const regRes2 = await fetch(`${REST_URL}/api/account/register`, {
217
+ const regRes2 = await apiFetch(`${REST_URL}/api/account/register`, {
201
218
  method: "POST",
202
219
  headers: { "Content-Type": "application/json" },
203
220
  body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
@@ -217,6 +234,31 @@ let AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id
217
234
  let TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
218
235
  let CAPABILITIES: string[] = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
219
236
 
237
+ // Native fetch, captured before the file-wide call-site rename to apiFetch so the
238
+ // wrapper below can't recurse into itself.
239
+ const nativeFetch = fetch;
240
+ // All AgentsChat REST goes through apiFetch so every call gets, from one place:
241
+ // (1) a timeout — a hung hub call must never block a tool forever; and
242
+ // (2) the bearer token, injected only when absent and TOKEN is set (so the few
243
+ // conditional-auth sites keep their exact semantics). init is otherwise passed
244
+ // through untouched, so callers keep using r.ok / r.text() / r.json().
245
+ const REST_TIMEOUT_MS = 15_000;
246
+ async function apiFetch(
247
+ input: string | URL,
248
+ init: RequestInit = {},
249
+ timeoutMs = REST_TIMEOUT_MS,
250
+ ): Promise<Response> {
251
+ const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
252
+ if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
253
+ const controller = new AbortController();
254
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
255
+ try {
256
+ return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
257
+ } finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+
220
262
  // Update display name if provided via CLI
221
263
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
222
264
  profile.display_name = cliArgs.name;
@@ -228,7 +270,7 @@ if (profile.token && profile.token !== "dev-token") {
228
270
  // /api/account/:id now requires auth (server tick 88 info-leak
229
271
  // fix). Without the Bearer header the welcome/claim banner
230
272
  // silently skipped on every MCP startup.
231
- const acctRes = await fetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
273
+ const acctRes = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
232
274
  headers: { "Authorization": `Bearer ${profile.token}` },
233
275
  });
234
276
  if (acctRes.ok) {
@@ -256,6 +298,47 @@ try {
256
298
  } catch {}
257
299
 
258
300
  let ws: WebSocket | null = null;
301
+
302
+ // ── Agent "thinking" typing heartbeat ────────────────────────────────────────
303
+ // While we process an inbound DM/@mention, pulse a cross-pod ephemeral typing
304
+ // frame (~2s) so a human on ANY pod sees the agent think for the whole duration.
305
+ // cross_pod:true → the hub fans out via publishToRemoteInstances; typing frames
306
+ // are never persisted (deliverLocalOnly), so this is zero-write. Rate 2s ≪ the
307
+ // per-agent 30/10s WS inbound cap. Stops when we reply to the channel, at a
308
+ // safety cap, or on shutdown — all timers tracked, so no leak. Survives a brief
309
+ // reconnect (frames no-op while the socket is down, resume on the new socket).
310
+ const TYPING_HEARTBEAT_MS = 2_000;
311
+ const TYPING_HEARTBEAT_MAX_MS = 120_000;
312
+ const typingHeartbeats = new Map<string, { interval: ReturnType<typeof setInterval>; cap: ReturnType<typeof setTimeout> }>();
313
+ function sendTypingFrame(channelId: string) {
314
+ if (ws && ws.readyState === WebSocket.OPEN) {
315
+ try {
316
+ ws.send(JSON.stringify({ type: "typing", channel_id: channelId, sender_id: AGENT_ID, cross_pod: true }));
317
+ } catch {}
318
+ }
319
+ }
320
+ function startTypingHeartbeat(channelId: string) {
321
+ if (!channelId) return;
322
+ stopTypingHeartbeat(channelId); // reset if one is already running for this channel
323
+ sendTypingFrame(channelId); // immediate first pulse
324
+ const interval = setInterval(() => sendTypingFrame(channelId), TYPING_HEARTBEAT_MS);
325
+ const cap = setTimeout(() => stopTypingHeartbeat(channelId), TYPING_HEARTBEAT_MAX_MS);
326
+ typingHeartbeats.set(channelId, { interval, cap });
327
+ }
328
+ function stopTypingHeartbeat(channelId: string) {
329
+ const h = typingHeartbeats.get(channelId);
330
+ if (!h) return;
331
+ clearInterval(h.interval);
332
+ clearTimeout(h.cap);
333
+ typingHeartbeats.delete(channelId);
334
+ }
335
+ function stopAllTypingHeartbeats() {
336
+ for (const h of typingHeartbeats.values()) {
337
+ clearInterval(h.interval);
338
+ clearTimeout(h.cap);
339
+ }
340
+ typingHeartbeats.clear();
341
+ }
259
342
  let sessionId: string | null = null;
260
343
  let shuttingDown = false;
261
344
  let transport: StdioServerTransport | null = null;
@@ -518,8 +601,7 @@ For non-trivial AgentsChat work, start from Workspace Graph/OKR state, preserve
518
601
 
519
602
  // --- Tools ---
520
603
 
521
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
522
- tools: filterVisibleTools([
604
+ const ALL_TOOL_DEFS = [
523
605
  {
524
606
  name: "reply",
525
607
  description: "Reply to an AgentsChat message. Pass the chat_id (channel_id) from the channel tag.",
@@ -1019,6 +1101,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1019
1101
  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.",
1020
1102
  },
1021
1103
  task_id: { type: "string", description: "Anchor task id; only meaningful with view=related" },
1104
+ shape: { type: "string", enum: ["summary"], description: "shape=summary returns a compact scan view (KR one-liners + task rollups, only doing/blocked expanded) — far fewer tokens. Drill into one objective with objective_id for the full subtree." },
1105
+ objective_id: { type: "string", description: "Return the full subtree (KRs + tasks + comments) for a single objective." },
1022
1106
  },
1023
1107
  },
1024
1108
  },
@@ -1264,15 +1348,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1264
1348
  required: ["chat_id", "doc_id"],
1265
1349
  },
1266
1350
  },
1267
- ]),
1268
- }));
1269
-
1270
- /** Redact sensitive tokens from outgoing message content */
1271
- function redactSecrets(text: string): string {
1272
- return text
1273
- .replace(/ac_[A-Za-z0-9]{16,}/g, "ac_***REDACTED***")
1274
- .replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "***JWT_REDACTED***");
1275
- }
1351
+ ];
1352
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: filterVisibleTools(ALL_TOOL_DEFS) }));
1353
+ // Runtime input-validation registry: tool name → its declared inputSchema.
1354
+ // Consulted at dispatch to reject contract-violating args with a clear isError (B1b).
1355
+ const TOOL_INPUT_SCHEMAS: Map<string, any> = new Map(
1356
+ (ALL_TOOL_DEFS as any[]).map((t) => [t.name, t.inputSchema]),
1357
+ );
1358
+
1359
+ // redactSecrets now lives in ./redact.ts (imported above) so it can be
1360
+ // unit-tested without loading this side-effecting entrypoint.
1276
1361
 
1277
1362
  /**
1278
1363
  * Per-channel member cache for the bare-@-to-paren resolver below.
@@ -1287,7 +1372,7 @@ async function fetchChannelMembers(chatId: string): Promise<{ agent_id: string;
1287
1372
  const hit = memberCache.get(chatId);
1288
1373
  if (hit && now - hit.at < MEMBER_CACHE_TTL_MS) return hit.members;
1289
1374
  try {
1290
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1375
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1291
1376
  headers: { "Authorization": `Bearer ${TOKEN}` },
1292
1377
  });
1293
1378
  if (!r.ok) return hit?.members || [];
@@ -1329,9 +1414,43 @@ async function resolveBareMentions(chatId: string, text: string): Promise<string
1329
1414
  });
1330
1415
  }
1331
1416
 
1417
+ // ── Tool handler registry (strangler-fig migration off the if-chain) ──────────
1418
+ // Handlers registered here are dispatched O(1) in the CallTool handler below,
1419
+ // after the arg-validation + extended-compat + visibility preamble; any tool not
1420
+ // yet migrated falls through to the legacy if-chain. Handlers close over module
1421
+ // state (ws, TOKEN, apiFetch, AGENT_ID, …) exactly as the inline blocks did, and
1422
+ // are populated at module load. Migrating in verified slices, not a big-bang.
1423
+ type ToolHandler = (args: any, name: string, request: any) => Promise<{ content: any[]; isError?: boolean }>;
1424
+ const HANDLERS = new Map<string, ToolHandler>();
1425
+
1426
+ HANDLERS.set("send_typing", async (args) => {
1427
+ const { chat_id } = args as { chat_id: string };
1428
+ if (ws && ws.readyState === WebSocket.OPEN) {
1429
+ ws.send(JSON.stringify({
1430
+ type: "typing",
1431
+ channel_id: chat_id,
1432
+ sender_id: AGENT_ID,
1433
+ cross_pod: true, // agent-originated → hub fans out cross-pod (humans on other pods see it)
1434
+ }));
1435
+ }
1436
+ return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1437
+ });
1438
+
1332
1439
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1333
1440
  let { name, arguments: args } = request.params;
1334
1441
  let viaExtendedCompat = false;
1442
+ // Central failure boundary: any handler that throws (network, JSON, ws.send,
1443
+ // unexpected shape) becomes an isError result instead of a silent/opaque
1444
+ // SDK error, so the calling agent always learns the call failed. Explicit
1445
+ // error returns below carry isError:true individually.
1446
+ try {
1447
+ // Validate args against the tool's declared inputSchema (required present +
1448
+ // declared types) before any side effect. Permissive: unknown tools and
1449
+ // undeclared fields pass through; only contract violations are rejected.
1450
+ if (TOOL_INPUT_SCHEMAS.has(name)) {
1451
+ const argErr = validateToolArgs(TOOL_INPUT_SCHEMAS.get(name), args);
1452
+ if (argErr) return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
1453
+ }
1335
1454
 
1336
1455
  if (name === "list_skills") {
1337
1456
  const { chat_id } = (args || {}) as { chat_id?: string };
@@ -1345,7 +1464,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1345
1464
  };
1346
1465
  if (chat_id) {
1347
1466
  try {
1348
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs`, {
1467
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs`, {
1349
1468
  headers: { "Authorization": `Bearer ${TOKEN}` },
1350
1469
  });
1351
1470
  if (r.ok) out.channel_skills = extractChannelDocsPayload(JSON.parse(await r.text())).filter(isSkillDoc).map(compactSkillDoc);
@@ -1356,10 +1475,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1356
1475
  }
1357
1476
  // Personal skills (per-owner, follow you across agents). sync_skill(name=…) / load via GET /api/skills/:name.
1358
1477
  try {
1359
- const pr = await fetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1478
+ const pr = await apiFetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1360
1479
  if (pr.ok) out.personal_skills = (JSON.parse(await pr.text()).skills) || [];
1361
1480
  } catch { /* best-effort */ }
1362
- return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1481
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
1363
1482
  }
1364
1483
 
1365
1484
  if (name === "load_skill") {
@@ -1367,12 +1486,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1367
1486
  // CHANNEL skill: chat_id + doc_id (full frontmatter/metadata handling).
1368
1487
  if (chat_id && doc_id) {
1369
1488
  try {
1370
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1489
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1371
1490
  headers: { "Authorization": `Bearer ${TOKEN}` },
1372
1491
  });
1373
1492
  const text = await r.text();
1374
1493
  if (!r.ok) {
1375
- return { content: [{ type: "text", text: `load_skill (channel) failed (${r.status}): ${text.slice(0, 200)}` }] };
1494
+ return { content: [{ type: "text", text: `load_skill (channel) failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
1376
1495
  }
1377
1496
  const doc = JSON.parse(text);
1378
1497
  const body = doc?.body_markdown ?? doc?.bodyMarkdown ?? "";
@@ -1401,14 +1520,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1401
1520
  }],
1402
1521
  };
1403
1522
  } catch (e: any) {
1404
- return { content: [{ type: "text", text: `load_skill (channel) network/parse error: ${String(e?.message || e).slice(0, 120)}` }] };
1523
+ return { content: [{ type: "text", text: `load_skill (channel) network/parse error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1405
1524
  }
1406
1525
  }
1407
1526
  // GLOBAL skill: skill_id (default workspace-driven-eng).
1408
1527
  const id = skill_id || DEFAULT_GLOBAL_SKILL_ID;
1409
1528
  const skill = GLOBAL_SKILLS[id];
1410
1529
  if (!skill) {
1411
- return { content: [{ type: "text", text: `Unknown global skill: ${id}` }] };
1530
+ return { content: [{ type: "text", text: `Unknown global skill: ${id}` }], isError: true };
1412
1531
  }
1413
1532
  return { content: [{ type: "text", text: `${skill.body}\n\nLoaded as global skill "${id}".` }] };
1414
1533
  }
@@ -1416,20 +1535,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1416
1535
  if (name === "save_memory") {
1417
1536
  const a = (args || {}) as { name?: string; body?: string; description?: string };
1418
1537
  if (!a.name || !a.body) {
1419
- return { content: [{ type: "text", text: "save_memory needs name (slug) + body (markdown). Optional description (one-line index hook)." }] };
1538
+ return { content: [{ type: "text", text: "save_memory needs name (slug) + body (markdown). Optional description (one-line index hook)." }], isError: true };
1420
1539
  }
1421
1540
  try {
1422
- const r = await fetch(`${REST_URL}/api/memory/${encodeURIComponent(a.name)}`, {
1541
+ const r = await apiFetch(`${REST_URL}/api/memory/${encodeURIComponent(a.name)}`, {
1423
1542
  method: "PUT",
1424
1543
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1425
1544
  body: JSON.stringify({ body_markdown: a.body, description: a.description }),
1426
1545
  });
1427
1546
  const text = await r.text();
1428
- if (!r.ok) return { content: [{ type: "text", text: `save_memory failed (${r.status}): ${text.slice(0, 240)}` }] };
1547
+ if (!r.ok) return { content: [{ type: "text", text: `save_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1429
1548
  const resp = JSON.parse(text);
1430
1549
  return { content: [{ type: "text", text: `Saved memory "${resp.name}" (v${resp.version}, ${resp.bytes}B) under your agent_id. Restore later: load_memory (index) → load_memory("${resp.name}").` }] };
1431
1550
  } catch (e: any) {
1432
- return { content: [{ type: "text", text: `save_memory network error: ${String(e?.message || e).slice(0, 120)}` }] };
1551
+ return { content: [{ type: "text", text: `save_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1433
1552
  }
1434
1553
  }
1435
1554
 
@@ -1437,9 +1556,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1437
1556
  const a = (args || {}) as { name?: string };
1438
1557
  try {
1439
1558
  const path = a.name ? `/api/memory/${encodeURIComponent(a.name)}` : `/api/memory`;
1440
- const r = await fetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1559
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1441
1560
  const text = await r.text();
1442
- if (!r.ok) return { content: [{ type: "text", text: `load_memory failed (${r.status}): ${text.slice(0, 240)}` }] };
1561
+ if (!r.ok) return { content: [{ type: "text", text: `load_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1443
1562
  const resp = JSON.parse(text);
1444
1563
  if (a.name) {
1445
1564
  return { content: [{ type: "text", text: `# memory: ${resp.name} (v${resp.version})\n\n${resp.body_markdown || ""}` }] };
@@ -1449,14 +1568,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1449
1568
  const idx = items.map((m: any) => `- ${m.name}${m.description ? ` — ${m.description}` : ""}`).join("\n");
1450
1569
  return { content: [{ type: "text", text: `Your memory index (${items.length} docs). Load one in full with load_memory("<name>"):\n\n${idx}` }] };
1451
1570
  } catch (e: any) {
1452
- return { content: [{ type: "text", text: `load_memory network error: ${String(e?.message || e).slice(0, 120)}` }] };
1571
+ return { content: [{ type: "text", text: `load_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1453
1572
  }
1454
1573
  }
1455
1574
 
1456
1575
  if (name === "save_skill") {
1457
1576
  const a = (args || {}) as { chat_id?: string; name?: string; description?: string; body?: string; doc_id?: string; level?: number };
1458
1577
  if (!a.name || !a.description) {
1459
- return { content: [{ type: "text", text: "save_skill needs name + description (body is the skill markdown). Pass chat_id for a CHANNEL skill, or OMIT chat_id for a PERSONAL skill that follows you across all your agents." }] };
1578
+ return { content: [{ type: "text", text: "save_skill needs name + description (body is the skill markdown). Pass chat_id for a CHANNEL skill, or OMIT chat_id for a PERSONAL skill that follows you across all your agents." }], isError: true };
1460
1579
  }
1461
1580
  // Skill markdown = YAML frontmatter (name, description) + body — the exact
1462
1581
  // shape parseSkillFrontmatter + the skill validators expect.
@@ -1466,17 +1585,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1466
1585
  if (!a.chat_id) {
1467
1586
  const pslug = String(a.doc_id || a.name).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "").slice(0, 64) || "skill";
1468
1587
  try {
1469
- const r = await fetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
1588
+ const r = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
1470
1589
  method: "PUT",
1471
1590
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1472
1591
  body: JSON.stringify({ body_markdown: md }),
1473
1592
  });
1474
1593
  const text = await r.text();
1475
- if (!r.ok) return { content: [{ type: "text", text: `save_skill (personal) failed (${r.status}): ${text.slice(0, 240)}` }] };
1594
+ if (!r.ok) return { content: [{ type: "text", text: `save_skill (personal) failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1476
1595
  const resp = JSON.parse(text);
1477
1596
  return { content: [{ type: "text", text: `Saved PERSONAL skill "${a.name}" as "${pslug}" (v${resp.version}) — shared across all your agents. Pull/refresh: sync_skill(name="${pslug}"); list: list_skills.` }] };
1478
1597
  } catch (e: any) {
1479
- return { content: [{ type: "text", text: `save_skill (personal) network error: ${String(e?.message || e).slice(0, 120)}` }] };
1598
+ return { content: [{ type: "text", text: `save_skill (personal) network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1480
1599
  }
1481
1600
  }
1482
1601
  // CHANNEL skill (chat_id given). Stable doc id: caller-supplied, else slug of name.
@@ -1487,24 +1606,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1487
1606
  // If-Match: fetch current version (0 = create). The doc store uses
1488
1607
  // optimistic concurrency; "0" creates, current version updates.
1489
1608
  let ifMatch = "0";
1490
- const cur = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1609
+ const cur = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1491
1610
  if (cur.ok) {
1492
1611
  const curDoc = await cur.json().catch(() => null) as any;
1493
1612
  if (curDoc && curDoc.version != null) ifMatch = String(curDoc.version);
1494
1613
  }
1495
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, {
1614
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, {
1496
1615
  method: "PUT",
1497
1616
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json", "If-Match": ifMatch },
1498
1617
  body: JSON.stringify({ kind: "channel_skill", level, title: a.name, body_markdown: md }),
1499
1618
  });
1500
1619
  const text = await r.text();
1501
1620
  if (!r.ok) {
1502
- return { content: [{ type: "text", text: `save_skill failed (${r.status}): ${text.slice(0, 240)}` }] };
1621
+ return { content: [{ type: "text", text: `save_skill failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1503
1622
  }
1504
1623
  const verb = ifMatch === "0" ? "Saved" : "Updated";
1505
1624
  return { content: [{ type: "text", text: `${verb} skill "${a.name}" → ${a.chat_id}/${docId} (L${level}). Others load it with: load_skill(chat_id="${a.chat_id}", doc_id="${docId}") — discoverable via list_skills(chat_id="${a.chat_id}").` }] };
1506
1625
  } catch (e: any) {
1507
- return { content: [{ type: "text", text: `save_skill network error: ${String(e?.message || e).slice(0, 120)}` }] };
1626
+ return { content: [{ type: "text", text: `save_skill network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1508
1627
  }
1509
1628
  }
1510
1629
 
@@ -1519,29 +1638,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1519
1638
  const pBase = `${cacheDir}/personal__${safe(a.name)}`;
1520
1639
  const pMd = `${pBase}.md`; const pMeta = `${pBase}.json`;
1521
1640
  try {
1522
- const listR = await fetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1523
- if (!listR.ok) return { content: [{ type: "text", text: `sync_skill (personal): list failed (${listR.status})` }] };
1641
+ const listR = await apiFetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1642
+ if (!listR.ok) return { content: [{ type: "text", text: `sync_skill (personal): list failed (${listR.status})` }], isError: true };
1524
1643
  const meta = (JSON.parse(await listR.text()).skills || []).find((s: any) => s.name === a.name);
1525
- if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }] };
1644
+ if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }], isError: true };
1526
1645
  const currentVersion = Number(meta.version ?? 0);
1527
1646
  let cachedVersion: number | null = null;
1528
1647
  try { cachedVersion = Number(JSON.parse(await Bun.file(pMeta).text()).version); } catch {}
1529
1648
  if (cachedVersion !== null && cachedVersion === currentVersion) {
1530
1649
  return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} — no download. Read that file to run it.` }] };
1531
1650
  }
1532
- const bodyR = await fetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1533
- if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }] };
1651
+ const bodyR = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1652
+ if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
1534
1653
  const doc = JSON.parse(await bodyR.text());
1535
1654
  await Bun.write(pMd, String(doc?.body_markdown ?? ""));
1536
1655
  await Bun.write(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
1537
1656
  return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} → ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
1538
1657
  } catch (e: any) {
1539
- return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }] };
1658
+ return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
1540
1659
  }
1541
1660
  }
1542
1661
  // CHANNEL skill: chat_id + doc_id.
1543
1662
  if (!a.chat_id || !a.doc_id) {
1544
- return { content: [{ type: "text", text: "sync_skill needs (chat_id + doc_id) for a CHANNEL skill, or (name) for a PERSONAL skill." }] };
1663
+ return { content: [{ type: "text", text: "sync_skill needs (chat_id + doc_id) for a CHANNEL skill, or (name) for a PERSONAL skill." }], isError: true };
1545
1664
  }
1546
1665
  const base = `${cacheDir}/${safe(a.chat_id)}__${safe(a.doc_id)}`;
1547
1666
  const mdPath = `${base}.md`;
@@ -1549,11 +1668,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1549
1668
  try {
1550
1669
  // 1. Cheap version check: docs-list returns each doc's version WITHOUT the
1551
1670
  // body, so "is my local copy current?" costs one light call.
1552
- const listR = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1553
- if (!listR.ok) return { content: [{ type: "text", text: `sync_skill: docs-list failed (${listR.status})` }] };
1671
+ const listR = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1672
+ if (!listR.ok) return { content: [{ type: "text", text: `sync_skill: docs-list failed (${listR.status})` }], isError: true };
1554
1673
  const docs = extractChannelDocsPayload(JSON.parse(await listR.text()));
1555
1674
  const meta = docs.find((d: any) => (d?.id ?? d?.doc_id) === a.doc_id);
1556
- if (!meta) return { content: [{ type: "text", text: `sync_skill: skill "${a.doc_id}" not found in channel ${a.chat_id}` }] };
1675
+ if (!meta) return { content: [{ type: "text", text: `sync_skill: skill "${a.doc_id}" not found in channel ${a.chat_id}` }], isError: true };
1557
1676
  const currentVersion = Number(meta.version ?? 0);
1558
1677
  // 2. Local cache check — skip the body fetch if we already have this version.
1559
1678
  let cachedVersion: number | null = null;
@@ -1562,8 +1681,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1562
1681
  return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} — no download. Read that file to run it.` }] };
1563
1682
  }
1564
1683
  // 3. Missing/stale → fetch the body (the only expensive call, on the cold path).
1565
- const docR = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(a.doc_id)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1566
- if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }] };
1684
+ const docR = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(a.doc_id)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1685
+ if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }], isError: true };
1567
1686
  const doc = JSON.parse(await docR.text());
1568
1687
  const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
1569
1688
  // 4. Write the local mirror + version sidecar.
@@ -1572,7 +1691,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1572
1691
  const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
1573
1692
  return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} → ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
1574
1693
  } catch (e: any) {
1575
- return { content: [{ type: "text", text: `sync_skill error: ${String(e?.message || e).slice(0, 140)}` }] };
1694
+ return { content: [{ type: "text", text: `sync_skill error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
1576
1695
  }
1577
1696
  }
1578
1697
 
@@ -1598,7 +1717,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1598
1717
  const { group_name } = (args || {}) as { group_name: ToolGroupName };
1599
1718
  const group = TOOL_GROUPS.find((item) => item.name === group_name);
1600
1719
  if (!group) {
1601
- return { content: [{ type: "text", text: `Unknown tool group: ${String(group_name)}` }] };
1720
+ return { content: [{ type: "text", text: `Unknown tool group: ${String(group_name)}` }], isError: true };
1602
1721
  }
1603
1722
  const wasLoaded = loadedToolGroups.has(group.name);
1604
1723
  if (!wasLoaded) {
@@ -1623,7 +1742,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1623
1742
  const { tool_name, arguments: forwardedArgs } = (args || {}) as { tool_name?: string; arguments?: Record<string, unknown> };
1624
1743
  const groupName = tool_name ? TOOL_NAME_TO_GROUP.get(tool_name) : undefined;
1625
1744
  if (!tool_name || !groupName) {
1626
- return { content: [{ type: "text", text: `invoke_extended_tool only supports known extended tools.` }] };
1745
+ return { content: [{ type: "text", text: `invoke_extended_tool only supports known extended tools.` }], isError: true };
1627
1746
  }
1628
1747
  name = tool_name;
1629
1748
  args = forwardedArgs || {};
@@ -1643,8 +1762,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1643
1762
  }
1644
1763
  }
1645
1764
 
1765
+ // Registry dispatch: a migrated handler is looked up O(1) here; any tool not
1766
+ // yet migrated falls through to the legacy if-chain below (strangler-fig).
1767
+ {
1768
+ const registered = HANDLERS.get(name);
1769
+ if (registered) return await registered(args, name, request);
1770
+ }
1771
+
1646
1772
  if (name === "reply") {
1647
1773
  const { chat_id, text: rawText } = args as { chat_id: string; text: string };
1774
+ stopTypingHeartbeat(chat_id); // we're answering this channel → stop the "thinking" pulse
1648
1775
  // Order: resolve bare @<display_name> to paren form FIRST (so the
1649
1776
  // receiving MCP plugin's regex triggers), then redact secrets so
1650
1777
  // an accidental `ac_xxx` in the text gets masked regardless of
@@ -1652,7 +1779,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1652
1779
  const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
1653
1780
  // Use REST API for reliable delivery (WebSocket may be half-open after deploy)
1654
1781
  try {
1655
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1782
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1656
1783
  method: "POST",
1657
1784
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1658
1785
  body: JSON.stringify({
@@ -1682,18 +1809,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1682
1809
  }
1683
1810
  }
1684
1811
 
1685
- if (name === "send_typing") {
1686
- const { chat_id } = args as { chat_id: string };
1687
- if (ws && ws.readyState === WebSocket.OPEN) {
1688
- ws.send(JSON.stringify({
1689
- type: "typing",
1690
- channel_id: chat_id,
1691
- sender_id: AGENT_ID,
1692
- }));
1693
- }
1694
- return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1695
- }
1696
-
1697
1812
  if (name === "react") {
1698
1813
  const { chat_id, message_id, emoji, action } = args as any;
1699
1814
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -1734,8 +1849,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1734
1849
  }
1735
1850
 
1736
1851
  if (name === "edit_message") {
1737
- const { chat_id, message_id, new_content } = args as any;
1852
+ const { chat_id, message_id, new_content: rawNewContent } = args as any;
1853
+ if (typeof rawNewContent !== "string") {
1854
+ return { content: [{ type: "text", text: "Error: new_content (string) required" }] };
1855
+ }
1738
1856
  if (ws && ws.readyState === WebSocket.OPEN) {
1857
+ // Mirror reply(): resolve bare @mentions then redact secrets so an
1858
+ // accidental ac_/JWT in edited content is masked like every other
1859
+ // outbound content path (this was the one hole that bypassed it).
1860
+ const new_content = redactSecrets(await resolveBareMentions(chat_id, rawNewContent));
1739
1861
  ws.send(JSON.stringify({
1740
1862
  type: "edit_message", message_id, channel_id: chat_id,
1741
1863
  sender_id: AGENT_ID, new_content, timestamp: new Date().toISOString(),
@@ -1760,7 +1882,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1760
1882
  const { status_text, status_emoji } = args as any;
1761
1883
  if (ws && ws.readyState === WebSocket.OPEN) {
1762
1884
  ws.send(JSON.stringify({
1763
- type: "set_status", sender_id: AGENT_ID, status_text, status_emoji,
1885
+ type: "set_status", sender_id: AGENT_ID,
1886
+ status_text: typeof status_text === "string" ? redactSecrets(status_text) : status_text,
1887
+ status_emoji,
1764
1888
  }));
1765
1889
  return { content: [{ type: "text", text: `Status update dispatched: ${status_emoji || ''} ${status_text}` }] };
1766
1890
  }
@@ -1784,7 +1908,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1784
1908
  free_text?: string;
1785
1909
  };
1786
1910
  try {
1787
- const r = await fetch(`${REST_URL}/api/moderation/report`, {
1911
+ const r = await apiFetch(`${REST_URL}/api/moderation/report`, {
1788
1912
  method: "POST",
1789
1913
  headers: {
1790
1914
  "Content-Type": "application/json",
@@ -1799,11 +1923,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1799
1923
  });
1800
1924
  const text = await r.text();
1801
1925
  if (!r.ok) {
1802
- return { content: [{ type: "text", text: `report_message failed (${r.status}): ${text.slice(0, 240)}` }] };
1926
+ return { content: [{ type: "text", text: `report_message failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1803
1927
  }
1804
1928
  return { content: [{ type: "text", text }] };
1805
1929
  } catch (e: any) {
1806
- return { content: [{ type: "text", text: `report_message network error: ${String(e?.message || e).slice(0, 120)}` }] };
1930
+ return { content: [{ type: "text", text: `report_message network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1807
1931
  }
1808
1932
  }
1809
1933
 
@@ -1812,16 +1936,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1812
1936
  const qs = new URLSearchParams();
1813
1937
  if (agent_id) qs.set("agent_id", agent_id);
1814
1938
  try {
1815
- const r = await fetch(`${REST_URL}/api/me/moderation_history${qs.toString() ? `?${qs.toString()}` : ""}`, {
1939
+ const r = await apiFetch(`${REST_URL}/api/me/moderation_history${qs.toString() ? `?${qs.toString()}` : ""}`, {
1816
1940
  headers: { "Authorization": `Bearer ${TOKEN}` },
1817
1941
  });
1818
1942
  const text = await r.text();
1819
1943
  if (!r.ok) {
1820
- return { content: [{ type: "text", text: `list_my_moderation_history failed (${r.status}): ${text.slice(0, 240)}` }] };
1944
+ return { content: [{ type: "text", text: `list_my_moderation_history failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1821
1945
  }
1822
1946
  return { content: [{ type: "text", text }] };
1823
1947
  } catch (e: any) {
1824
- return { content: [{ type: "text", text: `list_my_moderation_history network error: ${String(e?.message || e).slice(0, 120)}` }] };
1948
+ return { content: [{ type: "text", text: `list_my_moderation_history network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1825
1949
  }
1826
1950
  }
1827
1951
 
@@ -1829,21 +1953,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1829
1953
  const { limit = 20 } = args as { limit?: number };
1830
1954
  const capped = Math.max(1, Math.min(Number(limit) || 20, 100));
1831
1955
  try {
1832
- const r = await fetch(`${REST_URL}/api/me/reports_submitted?limit=${capped}`, {
1956
+ const r = await apiFetch(`${REST_URL}/api/me/reports_submitted?limit=${capped}`, {
1833
1957
  headers: { "Authorization": `Bearer ${TOKEN}` },
1834
1958
  });
1835
1959
  const text = await r.text();
1836
1960
  if (!r.ok) {
1837
- return { content: [{ type: "text", text: `list_reports_i_submitted failed (${r.status}): ${text.slice(0, 240)}` }] };
1961
+ return { content: [{ type: "text", text: `list_reports_i_submitted failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1838
1962
  }
1839
1963
  return { content: [{ type: "text", text }] };
1840
1964
  } catch (e: any) {
1841
- return { content: [{ type: "text", text: `list_reports_i_submitted network error: ${String(e?.message || e).slice(0, 120)}` }] };
1965
+ return { content: [{ type: "text", text: `list_reports_i_submitted network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1842
1966
  }
1843
1967
  }
1844
1968
 
1845
1969
  if (name === "set_topic") {
1846
1970
  const { chat_id, topic } = args as any;
1971
+ if (typeof chat_id !== "string" || typeof topic !== "string") {
1972
+ return { content: [{ type: "text", text: "Error: chat_id and topic (strings) required" }] };
1973
+ }
1847
1974
  if (ws && ws.readyState === WebSocket.OPEN) {
1848
1975
  ws.send(JSON.stringify({ type: "set_topic", channel_id: chat_id, sender_id: AGENT_ID, topic }));
1849
1976
  return { content: [{ type: "text", text: `Topic update dispatched; server may reject (admin only): ${topic.slice(0,50)}` }] };
@@ -1853,6 +1980,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1853
1980
 
1854
1981
  if (name === "forward") {
1855
1982
  const { source_channel_id, target_channel_id, message_id } = args as any;
1983
+ if (typeof target_channel_id !== "string" || typeof message_id !== "string") {
1984
+ return { content: [{ type: "text", text: "Error: target_channel_id and message_id (strings) required" }] };
1985
+ }
1856
1986
  if (ws && ws.readyState === WebSocket.OPEN) {
1857
1987
  ws.send(JSON.stringify({
1858
1988
  type: "forward", id: crypto.randomUUID(),
@@ -1866,10 +1996,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1866
1996
 
1867
1997
  if (name === "search") {
1868
1998
  const { query, channel_id } = args as any;
1999
+ if (typeof query !== "string" || query.length === 0) {
2000
+ return { content: [{ type: "text", text: "Error: query (non-empty string) required" }] };
2001
+ }
1869
2002
  try {
1870
2003
  const params = new URLSearchParams({ q: query, limit: "20" });
1871
2004
  if (channel_id) params.set("channel_id", channel_id);
1872
- const r = await fetch(`${REST_URL}/api/search?${params}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2005
+ const r = await apiFetch(`${REST_URL}/api/search?${params}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2006
+ if (!r.ok) {
2007
+ return { content: [{ type: "text", text: `Search failed (${r.status})` }], isError: true };
2008
+ }
1873
2009
  const data = await r.json() as any;
1874
2010
  if (data.messages?.length > 0) {
1875
2011
  const results = data.messages.map((m: any) =>
@@ -1885,6 +2021,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1885
2021
 
1886
2022
  if (name === "vote") {
1887
2023
  const { proposal_id, decision, reason } = args as any;
2024
+ if (typeof proposal_id !== "string" || typeof decision !== "string") {
2025
+ return { content: [{ type: "text", text: "Error: proposal_id and decision (strings) required" }] };
2026
+ }
1888
2027
  if (ws && ws.readyState === WebSocket.OPEN) {
1889
2028
  ws.send(JSON.stringify({
1890
2029
  type: "vote", proposal_id, voter_id: AGENT_ID,
@@ -1917,7 +2056,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1917
2056
  async function channelBrief(chatId: string): Promise<string> {
1918
2057
  const get = async (path: string) => {
1919
2058
  try {
1920
- const r = await fetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2059
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1921
2060
  return r.ok ? await r.json() as any : null;
1922
2061
  } catch { return null; }
1923
2062
  };
@@ -1929,8 +2068,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1929
2068
  const memberIds: string[] = (membersData?.members || []).map((m: any) => m?.agent_id).filter(Boolean);
1930
2069
  let online: string[] = [];
1931
2070
  if (memberIds.length > 0) {
1932
- const pres = await get(`/api/presence?ids=${encodeURIComponent(memberIds.slice(0, 50).join(","))}`);
1933
- online = Object.entries(pres?.presence || {}).filter(([, v]) => v === "online").map(([k]) => k);
2071
+ // Batch the presence query in chunks of 50 so channels with >50 members
2072
+ // report accurately (was: memberIds.slice(0,50) — silently wrong past 50).
2073
+ const onlineSet = new Set<string>();
2074
+ for (let i = 0; i < memberIds.length; i += 50) {
2075
+ const batch = memberIds.slice(i, i + 50);
2076
+ const pres = await get(`/api/presence?ids=${encodeURIComponent(batch.join(","))}`);
2077
+ for (const [k, v] of Object.entries(pres?.presence || {})) {
2078
+ if (v === "online") onlineSet.add(k);
2079
+ }
2080
+ }
2081
+ online = [...onlineSet];
1934
2082
  }
1935
2083
  const allDocs = docsData ? extractChannelDocsPayload(docsData) : [];
1936
2084
  const skills = allDocs.filter(isSkillDoc).map((d: any) => ({ doc_id: d.id, title: d.title }));
@@ -1975,7 +2123,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1975
2123
  // Verify by checking membership
1976
2124
  try {
1977
2125
  await new Promise(r => setTimeout(r, 500)); // wait for server to process
1978
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2126
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1979
2127
  if (r.ok) {
1980
2128
  const data = await r.json() as any;
1981
2129
  const isMember = (data.members || []).some((m: any) => m.agent_id === AGENT_ID);
@@ -1997,12 +2145,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1997
2145
  // fall through to WS leave_channel if REST is unreachable so existing
1998
2146
  // server-side WS handler still fires and updates in-memory state.
1999
2147
  try {
2000
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/leave`, {
2148
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/leave`, {
2001
2149
  method: "POST",
2002
2150
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2003
2151
  body: "{}",
2004
2152
  });
2005
2153
  if (r.ok) {
2154
+ // Prune so knownChannels / lastSeenMessageTs don't grow unbounded and
2155
+ // turn every reconnect backfill into a serial REST fetch over channels
2156
+ // we've already left.
2157
+ knownChannels.delete(chat_id);
2158
+ if (lastSeenMessageTs.delete(chat_id)) scheduleLastSeenMessageTsSave();
2006
2159
  const data = await r.json().catch(() => ({})) as any;
2007
2160
  if (data.note === "not a member") {
2008
2161
  return { content: [{ type: "text", text: `Already not a member of ${chat_id.slice(0, 8)}` }] };
@@ -2010,13 +2163,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2010
2163
  return { content: [{ type: "text", text: `Left channel ${chat_id.slice(0, 8)}` }] };
2011
2164
  }
2012
2165
  if (r.status === 404) {
2013
- return { content: [{ type: "text", text: `Channel ${chat_id.slice(0, 8)} not found` }] };
2166
+ return { content: [{ type: "text", text: `Channel ${chat_id.slice(0, 8)} not found` }], isError: true };
2014
2167
  }
2015
2168
  return { content: [{ type: "text", text: `Leave failed with status ${r.status}` }] };
2016
2169
  } catch (e: any) {
2017
2170
  // REST unreachable — fall back to WS leave so at least in-memory state updates
2018
2171
  if (ws && ws.readyState === WebSocket.OPEN) {
2019
2172
  try { ws.send(JSON.stringify({ type: "leave_channel", channel_id: chat_id, agent_id: AGENT_ID })); } catch {}
2173
+ knownChannels.delete(chat_id);
2174
+ if (lastSeenMessageTs.delete(chat_id)) scheduleLastSeenMessageTsSave();
2020
2175
  return { content: [{ type: "text", text: `Leave sent via WS (REST unreachable: ${String(e?.message || e).slice(0, 60)})` }] };
2021
2176
  }
2022
2177
  return { content: [{ type: "text", text: `Leave failed — no connectivity` }] };
@@ -2034,7 +2189,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2034
2189
  if (name === "hidden_identity_join") {
2035
2190
  const { game_id } = args as any;
2036
2191
  try {
2037
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/join`, {
2192
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/join`, {
2038
2193
  method: "POST",
2039
2194
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2040
2195
  body: "{}",
@@ -2047,7 +2202,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2047
2202
  const activeNote = channelId ? ` HI active mode enabled for channel ${String(channelId).slice(0, 8)}.` : "";
2048
2203
  return { content: [{ type: "text", text: `Joined game ${String(game_id).slice(0, 8)} — ${count} players in lobby.${activeNote}` }] };
2049
2204
  }
2050
- return { content: [{ type: "text", text: `Join failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
2205
+ return { content: [{ type: "text", text: `Join failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2051
2206
  } catch (e: any) {
2052
2207
  return { content: [{ type: "text", text: `Join failed: ${String(e?.message || e).slice(0, 80)}` }] };
2053
2208
  }
@@ -2056,7 +2211,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2056
2211
  if (name === "hidden_identity_get_secret") {
2057
2212
  const { game_id } = args as any;
2058
2213
  try {
2059
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/secret`, {
2214
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/secret`, {
2060
2215
  headers: { "Authorization": `Bearer ${TOKEN}` },
2061
2216
  });
2062
2217
  const data = await r.json().catch(() => ({})) as any;
@@ -2086,7 +2241,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2086
2241
  }
2087
2242
  if (r.status === 403) return { content: [{ type: "text", text: `You are not a player in this game (403)` }] };
2088
2243
  if (r.status === 404) return { content: [{ type: "text", text: `Game or secret not allocated yet (game may still be in lobby)` }] };
2089
- return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }] };
2244
+ return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }], isError: true };
2090
2245
  } catch (e: any) {
2091
2246
  return { content: [{ type: "text", text: `Secret fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2092
2247
  }
@@ -2097,7 +2252,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2097
2252
  try {
2098
2253
  const body: any = { target_id };
2099
2254
  if (typeof reason === "string" && reason) body.reason = reason;
2100
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/vote`, {
2255
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/vote`, {
2101
2256
  method: "POST",
2102
2257
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2103
2258
  body: JSON.stringify(body),
@@ -2106,7 +2261,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2106
2261
  if (r.ok) {
2107
2262
  return { content: [{ type: "text", text: `Vote cast against ${String(target_id).slice(0, 12)} in round ${data?.round}` }] };
2108
2263
  }
2109
- return { content: [{ type: "text", text: `Vote failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
2264
+ return { content: [{ type: "text", text: `Vote failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2110
2265
  } catch (e: any) {
2111
2266
  return { content: [{ type: "text", text: `Vote failed: ${String(e?.message || e).slice(0, 80)}` }] };
2112
2267
  }
@@ -2115,7 +2270,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2115
2270
  if (name === "hidden_identity_advance") {
2116
2271
  const { game_id, to } = args as any;
2117
2272
  try {
2118
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/advance`, {
2273
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/advance`, {
2119
2274
  method: "POST",
2120
2275
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2121
2276
  body: JSON.stringify({ to }),
@@ -2124,8 +2279,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2124
2279
  if (r.ok) {
2125
2280
  return { content: [{ type: "text", text: `Phase advanced to ${data?.phase || to}, round ${data?.round ?? "?"}` }] };
2126
2281
  }
2127
- if (r.status === 409) return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }] };
2128
- return { content: [{ type: "text", text: `Advance failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
2282
+ if (r.status === 409) return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2283
+ return { content: [{ type: "text", text: `Advance failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2129
2284
  } catch (e: any) {
2130
2285
  return { content: [{ type: "text", text: `Advance failed: ${String(e?.message || e).slice(0, 80)}` }] };
2131
2286
  }
@@ -2134,7 +2289,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2134
2289
  if (name === "hidden_identity_get_state") {
2135
2290
  const { game_id } = args as any;
2136
2291
  try {
2137
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}`, {
2292
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}`, {
2138
2293
  headers: { "Authorization": `Bearer ${TOKEN}` },
2139
2294
  });
2140
2295
  const data = await r.json().catch(() => ({})) as any;
@@ -2145,7 +2300,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2145
2300
  }).join(", ");
2146
2301
  return { content: [{ type: "text", text: `Phase: ${g.phase}, Round: ${g.round}, Winner: ${g.winner_team || "—"}. Players: ${players}` }] };
2147
2302
  }
2148
- return { content: [{ type: "text", text: `Game state fetch failed (${r.status})` }] };
2303
+ return { content: [{ type: "text", text: `Game state fetch failed (${r.status})` }], isError: true };
2149
2304
  } catch (e: any) {
2150
2305
  return { content: [{ type: "text", text: `Game state fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2151
2306
  }
@@ -2153,6 +2308,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2153
2308
 
2154
2309
  if (name === "mark_read") {
2155
2310
  const { chat_id, last_read_id } = args as any;
2311
+ if (typeof chat_id !== "string" || typeof last_read_id !== "string") {
2312
+ return { content: [{ type: "text", text: "Error: chat_id and last_read_id (strings) required" }] };
2313
+ }
2156
2314
  if (ws && ws.readyState === WebSocket.OPEN) {
2157
2315
  ws.send(JSON.stringify({
2158
2316
  type: "read_receipt", channel_id: chat_id,
@@ -2168,7 +2326,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2168
2326
  let healthLine = "REST health: unknown";
2169
2327
  let authLine = "REST auth: unknown";
2170
2328
  try {
2171
- const r = await fetch(`${REST_URL}/health`);
2329
+ const r = await apiFetch(`${REST_URL}/health`);
2172
2330
  if (r.ok) {
2173
2331
  const h = await r.json() as any;
2174
2332
  const build = h?.build ? ` build=${h.build}` : "";
@@ -2181,7 +2339,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2181
2339
  healthLine = `REST health: error (${String(e?.message || e).slice(0, 80)})`;
2182
2340
  }
2183
2341
  try {
2184
- const r = await fetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2342
+ const r = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2185
2343
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
2186
2344
  });
2187
2345
  authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
@@ -2194,7 +2352,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2194
2352
  if (name === "list_channels") {
2195
2353
  const { limit = 50 } = args as any;
2196
2354
  try {
2197
- const r = await fetch(`${REST_URL}/api/channels/discover?limit=${Math.min(limit, 500)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2355
+ const r = await apiFetch(`${REST_URL}/api/channels/discover?limit=${Math.max(1, Math.min(Number(limit) || 50, 500))}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2198
2356
  if (r.ok) {
2199
2357
  const data = await r.json() as any;
2200
2358
  const channels = (data.channels || []);
@@ -2224,7 +2382,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2224
2382
  // DMs). DM channel ids are deterministic on iOS but the source of
2225
2383
  // truth for "does this DM exist between us" is server membership,
2226
2384
  // so we list + filter rather than replay the hash.
2227
- const r = await fetch(`${REST_URL}/api/channels/mine`, {
2385
+ const r = await apiFetch(`${REST_URL}/api/channels/mine`, {
2228
2386
  headers: { "Authorization": `Bearer ${TOKEN}` },
2229
2387
  });
2230
2388
  if (!r.ok) {
@@ -2237,7 +2395,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2237
2395
  for (const ch of channels) {
2238
2396
  if (ch?.type !== "direct") continue;
2239
2397
  try {
2240
- const mr = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(ch.id)}/members`, {
2398
+ const mr = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(ch.id)}/members`, {
2241
2399
  headers: { "Authorization": `Bearer ${TOKEN}` },
2242
2400
  });
2243
2401
  if (!mr.ok) continue;
@@ -2256,7 +2414,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2256
2414
 
2257
2415
  if (name === "list_loops") {
2258
2416
  try {
2259
- const r = await fetch(`${REST_URL}/api/loops/mine`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2417
+ const r = await apiFetch(`${REST_URL}/api/loops/mine`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2260
2418
  if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2261
2419
  const data = await r.json() as any;
2262
2420
  const loops = Array.isArray(data?.loops) ? data.loops : [];
@@ -2274,7 +2432,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2274
2432
 
2275
2433
  if (name === "my_entitlements") {
2276
2434
  try {
2277
- const r = await fetch(`${REST_URL}/api/me/entitlements`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2435
+ const r = await apiFetch(`${REST_URL}/api/me/entitlements`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2278
2436
  if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2279
2437
  const data = await r.json() as any;
2280
2438
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
@@ -2293,7 +2451,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2293
2451
  if (name === "list_members") {
2294
2452
  const { chat_id } = args as any;
2295
2453
  try {
2296
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2454
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2297
2455
  if (r.ok) {
2298
2456
  const data = await r.json() as any;
2299
2457
  const members = data.members || [];
@@ -2310,7 +2468,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2310
2468
  if (name === "get_history") {
2311
2469
  const { chat_id, limit = 20 } = args as any;
2312
2470
  try {
2313
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages?limit=${Math.min(limit, 100)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2471
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages?limit=${Math.max(1, Math.min(Number(limit) || 20, 100))}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2314
2472
  if (r.ok) {
2315
2473
  const data = await r.json() as any;
2316
2474
  const msgs = (data.messages || []).filter((m: any) => m.content !== "__typing__");
@@ -2342,13 +2500,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2342
2500
  // Find and load the profile
2343
2501
  const targetFile = nameToPath(profile_name);
2344
2502
  if (!existsSync(targetFile)) {
2345
- return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }] };
2503
+ return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }], isError: true };
2346
2504
  }
2347
2505
 
2348
2506
  const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
2349
2507
 
2350
2508
  // Stop heartbeat first to prevent race with old connection
2351
2509
  heartbeat.stop();
2510
+ // Cancel any pending backfill / reconnect from the old identity.
2511
+ if (backfillTimer) { clearTimeout(backfillTimer); backfillTimer = null; }
2512
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2352
2513
 
2353
2514
  // Close old connection, disable its reconnect handler
2354
2515
  if (ws) {
@@ -2377,35 +2538,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2377
2538
  const qs = new URLSearchParams();
2378
2539
  const normalizedLevel = normalizeChannelDocLevel(level);
2379
2540
  if (level !== undefined && normalizedLevel === null) {
2380
- return { content: [{ type: "text", text: "list_channel_docs failed: level must be 1|2|3|4" }] };
2541
+ return { content: [{ type: "text", text: "list_channel_docs failed: level must be 1|2|3|4" }], isError: true };
2381
2542
  }
2382
2543
  if (normalizedLevel !== null) qs.set("level", String(normalizedLevel));
2383
2544
  const url = `${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs${qs.toString() ? `?${qs}` : ""}`;
2384
2545
  try {
2385
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2546
+ const r = await apiFetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2386
2547
  const text = await r.text();
2387
2548
  if (!r.ok) {
2388
- return { content: [{ type: "text", text: `list_channel_docs failed (${r.status}): ${text.slice(0, 200)}` }] };
2549
+ return { content: [{ type: "text", text: `list_channel_docs failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2389
2550
  }
2390
2551
  return { content: [{ type: "text", text }] };
2391
2552
  } catch (e: any) {
2392
- return { content: [{ type: "text", text: `list_channel_docs network error: ${String(e?.message || e).slice(0, 120)}` }] };
2553
+ return { content: [{ type: "text", text: `list_channel_docs network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2393
2554
  }
2394
2555
  }
2395
2556
 
2396
2557
  if (name === "get_channel_doc") {
2397
2558
  const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
2398
2559
  try {
2399
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2560
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2400
2561
  headers: { "Authorization": `Bearer ${TOKEN}` },
2401
2562
  });
2402
2563
  const text = await r.text();
2403
2564
  if (!r.ok) {
2404
- return { content: [{ type: "text", text: `get_channel_doc failed (${r.status}): ${text.slice(0, 200)}` }] };
2565
+ return { content: [{ type: "text", text: `get_channel_doc failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2405
2566
  }
2406
2567
  return { content: [{ type: "text", text }] };
2407
2568
  } catch (e: any) {
2408
- return { content: [{ type: "text", text: `get_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }] };
2569
+ return { content: [{ type: "text", text: `get_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2409
2570
  }
2410
2571
  }
2411
2572
 
@@ -2421,10 +2582,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2421
2582
  };
2422
2583
  const normalizedLevel = normalizeChannelDocLevel(level);
2423
2584
  if (normalizedLevel === null) {
2424
- return { content: [{ type: "text", text: "upsert_channel_doc failed: level must be 1|2|3|4" }] };
2585
+ return { content: [{ type: "text", text: "upsert_channel_doc failed: level must be 1|2|3|4" }], isError: true };
2425
2586
  }
2426
2587
  try {
2427
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2588
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2428
2589
  method: "PUT",
2429
2590
  headers: {
2430
2591
  "Content-Type": "application/json",
@@ -2435,27 +2596,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2435
2596
  });
2436
2597
  const text = await r.text();
2437
2598
  if (!r.ok) {
2438
- return { content: [{ type: "text", text: `upsert_channel_doc failed (${r.status}): ${text.slice(0, 240)}` }] };
2599
+ return { content: [{ type: "text", text: `upsert_channel_doc failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2439
2600
  }
2440
2601
  return { content: [{ type: "text", text }] };
2441
2602
  } catch (e: any) {
2442
- return { content: [{ type: "text", text: `upsert_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }] };
2603
+ return { content: [{ type: "text", text: `upsert_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2443
2604
  }
2444
2605
  }
2445
2606
 
2446
2607
  if (name === "list_channel_doc_revisions") {
2447
2608
  const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
2448
2609
  try {
2449
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}/revisions`, {
2610
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}/revisions`, {
2450
2611
  headers: { "Authorization": `Bearer ${TOKEN}` },
2451
2612
  });
2452
2613
  const text = await r.text();
2453
2614
  if (!r.ok) {
2454
- return { content: [{ type: "text", text: `list_channel_doc_revisions failed (${r.status}): ${text.slice(0, 200)}` }] };
2615
+ return { content: [{ type: "text", text: `list_channel_doc_revisions failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2455
2616
  }
2456
2617
  return { content: [{ type: "text", text }] };
2457
2618
  } catch (e: any) {
2458
- return { content: [{ type: "text", text: `list_channel_doc_revisions network error: ${String(e?.message || e).slice(0, 120)}` }] };
2619
+ return { content: [{ type: "text", text: `list_channel_doc_revisions network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2459
2620
  }
2460
2621
  }
2461
2622
 
@@ -2463,7 +2624,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2463
2624
  // Maps 1:1 to the server-side routes shipped in commit 5229aab
2464
2625
  // (projects/AgentChat/Server/src/okr.ts + index.ts dispatch block).
2465
2626
  if (name === "okr_list") {
2466
- 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 };
2627
+ const { owner, status, horizon, include_archived, view, task_id, shape, objective_id } = args as { owner?: string; status?: string; horizon?: string; include_archived?: boolean; view?: string; task_id?: string; shape?: string; objective_id?: string };
2467
2628
  const qs = new URLSearchParams();
2468
2629
  if (owner) qs.set("owner", owner);
2469
2630
  if (status) qs.set("status", status);
@@ -2471,17 +2632,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2471
2632
  if (include_archived) qs.set("include_archived", "true");
2472
2633
  if (view) qs.set("view", view);
2473
2634
  if (task_id) qs.set("task_id", task_id);
2635
+ if (shape) qs.set("shape", shape);
2636
+ if (objective_id) qs.set("objective_id", objective_id);
2474
2637
  const url = `${REST_URL}/api/okr/objectives${qs.toString() ? "?" + qs.toString() : ""}`;
2475
2638
  try {
2476
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2639
+ const r = await apiFetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2477
2640
  if (!r.ok) {
2478
2641
  const err = await r.text();
2479
- return { content: [{ type: "text", text: `okr_list failed (${r.status}): ${err.slice(0, 120)}` }] };
2642
+ return { content: [{ type: "text", text: `okr_list failed (${r.status}): ${err.slice(0, 120)}` }], isError: true };
2480
2643
  }
2481
2644
  const data = await r.json() as any;
2482
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2645
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
2483
2646
  } catch (e: any) {
2484
- return { content: [{ type: "text", text: `okr_list network error: ${String(e?.message || e).slice(0, 120)}` }] };
2647
+ return { content: [{ type: "text", text: `okr_list network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2485
2648
  }
2486
2649
  }
2487
2650
 
@@ -2495,18 +2658,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2495
2658
  if (due) body.due = due;
2496
2659
  if (discussion_channel_id) body.discussion_channel_id = discussion_channel_id;
2497
2660
  try {
2498
- const r = await fetch(`${REST_URL}/api/okr/objectives`, {
2661
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives`, {
2499
2662
  method: "POST",
2500
2663
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2501
2664
  body: JSON.stringify(body),
2502
2665
  });
2503
2666
  const text = await r.text();
2504
2667
  if (!r.ok) {
2505
- return { content: [{ type: "text", text: `okr_create_objective failed (${r.status}): ${text.slice(0, 160)}` }] };
2668
+ return { content: [{ type: "text", text: `okr_create_objective failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2506
2669
  }
2507
2670
  return { content: [{ type: "text", text: `Created: ${text}` }] };
2508
2671
  } catch (e: any) {
2509
- return { content: [{ type: "text", text: `okr_create_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
2672
+ return { content: [{ type: "text", text: `okr_create_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2510
2673
  }
2511
2674
  }
2512
2675
 
@@ -2519,18 +2682,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2519
2682
  if (Array.isArray(depends_on) && depends_on.length > 0) body.depends_on = depends_on;
2520
2683
  if (due) body.due = due;
2521
2684
  try {
2522
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/tasks`, {
2685
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/tasks`, {
2523
2686
  method: "POST",
2524
2687
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2525
2688
  body: JSON.stringify(body),
2526
2689
  });
2527
2690
  const text = await r.text();
2528
2691
  if (!r.ok) {
2529
- return { content: [{ type: "text", text: `okr_add_task failed (${r.status}): ${text.slice(0, 160)}` }] };
2692
+ return { content: [{ type: "text", text: `okr_add_task failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2530
2693
  }
2531
2694
  return { content: [{ type: "text", text: `Added: ${text}` }] };
2532
2695
  } catch (e: any) {
2533
- return { content: [{ type: "text", text: `okr_add_task network error: ${String(e?.message || e).slice(0, 120)}` }] };
2696
+ return { content: [{ type: "text", text: `okr_add_task network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2534
2697
  }
2535
2698
  }
2536
2699
 
@@ -2544,18 +2707,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2544
2707
  if (Array.isArray(depends_on)) patch.depends_on = depends_on;
2545
2708
  if (due) patch.due = due;
2546
2709
  try {
2547
- const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}`, {
2710
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}`, {
2548
2711
  method: "PATCH",
2549
2712
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2550
2713
  body: JSON.stringify(patch),
2551
2714
  });
2552
2715
  const text = await r.text();
2553
2716
  if (!r.ok) {
2554
- return { content: [{ type: "text", text: `okr_update_task failed (${r.status}): ${text.slice(0, 160)}` }] };
2717
+ return { content: [{ type: "text", text: `okr_update_task failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2555
2718
  }
2556
2719
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2557
2720
  } catch (e: any) {
2558
- return { content: [{ type: "text", text: `okr_update_task network error: ${String(e?.message || e).slice(0, 120)}` }] };
2721
+ return { content: [{ type: "text", text: `okr_update_task network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2559
2722
  }
2560
2723
  }
2561
2724
 
@@ -2563,34 +2726,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2563
2726
  const { task_id } = args as { task_id: string };
2564
2727
  const path = name === "okr_task_blockers" ? "blockers" : "blocks";
2565
2728
  try {
2566
- const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/${path}`, {
2729
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/${path}`, {
2567
2730
  headers: { "Authorization": `Bearer ${TOKEN}` },
2568
2731
  });
2569
2732
  const text = await r.text();
2570
2733
  if (!r.ok) {
2571
- return { content: [{ type: "text", text: `${name} failed (${r.status}): ${text.slice(0, 160)}` }] };
2734
+ return { content: [{ type: "text", text: `${name} failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2572
2735
  }
2573
2736
  return { content: [{ type: "text", text }] };
2574
2737
  } catch (e: any) {
2575
- return { content: [{ type: "text", text: `${name} network error: ${String(e?.message || e).slice(0, 120)}` }] };
2738
+ return { content: [{ type: "text", text: `${name} network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2576
2739
  }
2577
2740
  }
2578
2741
 
2579
2742
  if (name === "okr_open_thread") {
2580
2743
  const { target_type, target_id } = args as { target_type: string; target_id: string };
2581
2744
  try {
2582
- const r = await fetch(`${REST_URL}/api/okr/threads`, {
2745
+ const r = await apiFetch(`${REST_URL}/api/okr/threads`, {
2583
2746
  method: "POST",
2584
2747
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2585
2748
  body: JSON.stringify({ target_type, target_id }),
2586
2749
  });
2587
2750
  const text = await r.text();
2588
2751
  if (!r.ok) {
2589
- return { content: [{ type: "text", text: `okr_open_thread failed (${r.status}): ${text.slice(0, 160)}` }] };
2752
+ return { content: [{ type: "text", text: `okr_open_thread failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2590
2753
  }
2591
2754
  return { content: [{ type: "text", text }] };
2592
2755
  } catch (e: any) {
2593
- return { content: [{ type: "text", text: `okr_open_thread network error: ${String(e?.message || e).slice(0, 120)}` }] };
2756
+ return { content: [{ type: "text", text: `okr_open_thread network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2594
2757
  }
2595
2758
  }
2596
2759
 
@@ -2602,53 +2765,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2602
2765
  if (typeof current === "number") body.current = current;
2603
2766
  if (risk_level) body.risk_level = risk_level;
2604
2767
  try {
2605
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/krs`, {
2768
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/krs`, {
2606
2769
  method: "POST",
2607
2770
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2608
2771
  body: JSON.stringify(body),
2609
2772
  });
2610
2773
  const text = await r.text();
2611
2774
  if (!r.ok) {
2612
- return { content: [{ type: "text", text: `okr_add_kr failed (${r.status}): ${text.slice(0, 160)}` }] };
2775
+ return { content: [{ type: "text", text: `okr_add_kr failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2613
2776
  }
2614
2777
  return { content: [{ type: "text", text: `Added: ${text}` }] };
2615
2778
  } catch (e: any) {
2616
- return { content: [{ type: "text", text: `okr_add_kr network error: ${String(e?.message || e).slice(0, 120)}` }] };
2779
+ return { content: [{ type: "text", text: `okr_add_kr network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2617
2780
  }
2618
2781
  }
2619
2782
 
2620
2783
  if (name === "archive_objective") {
2621
2784
  const { objective_id, completion_summary } = args as { objective_id: string; completion_summary?: string };
2622
2785
  try {
2623
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/archive`, {
2786
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/archive`, {
2624
2787
  method: "POST",
2625
2788
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2626
2789
  body: JSON.stringify(completion_summary !== undefined ? { completion_summary } : {}),
2627
2790
  });
2628
2791
  const text = await r.text();
2629
2792
  if (!r.ok) {
2630
- return { content: [{ type: "text", text: `archive_objective failed (${r.status}): ${text.slice(0, 200)}` }] };
2793
+ return { content: [{ type: "text", text: `archive_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2631
2794
  }
2632
2795
  return { content: [{ type: "text", text: `Archived: ${text}` }] };
2633
2796
  } catch (e: any) {
2634
- return { content: [{ type: "text", text: `archive_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
2797
+ return { content: [{ type: "text", text: `archive_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2635
2798
  }
2636
2799
  }
2637
2800
 
2638
2801
  if (name === "unarchive_objective") {
2639
2802
  const { objective_id } = args as { objective_id: string };
2640
2803
  try {
2641
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/unarchive`, {
2804
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/unarchive`, {
2642
2805
  method: "POST",
2643
2806
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2644
2807
  });
2645
2808
  const text = await r.text();
2646
2809
  if (!r.ok) {
2647
- return { content: [{ type: "text", text: `unarchive_objective failed (${r.status}): ${text.slice(0, 200)}` }] };
2810
+ return { content: [{ type: "text", text: `unarchive_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2648
2811
  }
2649
2812
  return { content: [{ type: "text", text: `Unarchived: ${text}` }] };
2650
2813
  } catch (e: any) {
2651
- return { content: [{ type: "text", text: `unarchive_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
2814
+ return { content: [{ type: "text", text: `unarchive_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2652
2815
  }
2653
2816
  }
2654
2817
 
@@ -2657,18 +2820,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2657
2820
  const body: Record<string, unknown> = { current };
2658
2821
  if (risk_level) body.risk_level = risk_level;
2659
2822
  try {
2660
- const r = await fetch(`${REST_URL}/api/okr/krs/${encodeURIComponent(kr_id)}/progress`, {
2823
+ const r = await apiFetch(`${REST_URL}/api/okr/krs/${encodeURIComponent(kr_id)}/progress`, {
2661
2824
  method: "PATCH",
2662
2825
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2663
2826
  body: JSON.stringify(body),
2664
2827
  });
2665
2828
  const text = await r.text();
2666
2829
  if (!r.ok) {
2667
- return { content: [{ type: "text", text: `okr_set_kr_progress failed (${r.status}): ${text.slice(0, 160)}` }] };
2830
+ return { content: [{ type: "text", text: `okr_set_kr_progress failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2668
2831
  }
2669
2832
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2670
2833
  } catch (e: any) {
2671
- return { content: [{ type: "text", text: `okr_set_kr_progress network error: ${String(e?.message || e).slice(0, 120)}` }] };
2834
+ return { content: [{ type: "text", text: `okr_set_kr_progress network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2672
2835
  }
2673
2836
  }
2674
2837
 
@@ -2676,18 +2839,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2676
2839
  const { task_id, text: rawText } = args as { task_id: string; text: string };
2677
2840
  const text = redactSecrets(rawText);
2678
2841
  try {
2679
- const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/comments`, {
2842
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/comments`, {
2680
2843
  method: "POST",
2681
2844
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2682
2845
  body: JSON.stringify({ text }),
2683
2846
  });
2684
2847
  const body = await r.text();
2685
2848
  if (!r.ok) {
2686
- return { content: [{ type: "text", text: `okr_add_task_comment failed (${r.status}): ${body.slice(0, 160)}` }] };
2849
+ return { content: [{ type: "text", text: `okr_add_task_comment failed (${r.status}): ${body.slice(0, 160)}` }], isError: true };
2687
2850
  }
2688
2851
  return { content: [{ type: "text", text: `Commented: ${body}` }] };
2689
2852
  } catch (e: any) {
2690
- return { content: [{ type: "text", text: `okr_add_task_comment network error: ${String(e?.message || e).slice(0, 120)}` }] };
2853
+ return { content: [{ type: "text", text: `okr_add_task_comment network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2691
2854
  }
2692
2855
  }
2693
2856
 
@@ -2708,22 +2871,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2708
2871
  if (linked_docs !== undefined) body.linked_docs = linked_docs;
2709
2872
  if (linked_channel_docs !== undefined) body.linked_channel_docs = linked_channel_docs;
2710
2873
  try {
2711
- const r = await fetch(`${REST_URL}/api/okr/links/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`, {
2874
+ const r = await apiFetch(`${REST_URL}/api/okr/links/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`, {
2712
2875
  method: "PATCH",
2713
2876
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2714
2877
  body: JSON.stringify(body),
2715
2878
  });
2716
2879
  const text = await r.text();
2717
2880
  if (!r.ok) {
2718
- return { content: [{ type: "text", text: `okr_set_links failed (${r.status}): ${text.slice(0, 200)}` }] };
2881
+ return { content: [{ type: "text", text: `okr_set_links failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2719
2882
  }
2720
2883
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2721
2884
  } catch (e: any) {
2722
- return { content: [{ type: "text", text: `okr_set_links network error: ${String(e?.message || e).slice(0, 120)}` }] };
2885
+ return { content: [{ type: "text", text: `okr_set_links network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2723
2886
  }
2724
2887
  }
2725
2888
 
2726
- return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
2889
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2890
+ } catch (e: any) {
2891
+ return { content: [{ type: "text", text: `${name} failed: ${String(e?.message || e).slice(0, 300)}` }], isError: true };
2892
+ }
2727
2893
  });
2728
2894
 
2729
2895
  // --- WebSocket Connection ---
@@ -2733,13 +2899,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2733
2899
  const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
2734
2900
  function loadMentionTimestamps(): Map<string, string> {
2735
2901
  try {
2736
- const raw = require("fs").readFileSync(mentionTsFile, "utf-8");
2902
+ const raw = readFileSync(mentionTsFile, "utf-8");
2737
2903
  return new Map(Object.entries(JSON.parse(raw)));
2738
2904
  } catch { return new Map(); }
2739
2905
  }
2740
2906
  function saveMentionTimestamps(m: Map<string, string>) {
2741
2907
  try {
2742
- require("fs").writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2908
+ writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2743
2909
  } catch {}
2744
2910
  }
2745
2911
  const lastMentionTimestamp = loadMentionTimestamps();
@@ -2762,13 +2928,13 @@ const lastMentionTimestamp = loadMentionTimestamps();
2762
2928
  const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
2763
2929
  function loadLastSeenMessageTs(): Map<string, string> {
2764
2930
  try {
2765
- const raw = require("fs").readFileSync(lastSeenMessageTsFile, "utf-8");
2931
+ const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
2766
2932
  return new Map(Object.entries(JSON.parse(raw)));
2767
2933
  } catch { return new Map(); }
2768
2934
  }
2769
2935
  function saveLastSeenMessageTs(m: Map<string, string>) {
2770
2936
  try {
2771
- require("fs").writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2937
+ writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2772
2938
  } catch {}
2773
2939
  }
2774
2940
  const lastSeenMessageTs = loadLastSeenMessageTs();
@@ -2799,15 +2965,9 @@ function scheduleLastSeenMessageTsSave() {
2799
2965
  (lastSeenMessageTsTimer as any).unref?.();
2800
2966
  }
2801
2967
 
2802
- function normalizeTimestampForCursor(ts: string | undefined, mode: "before" | "after"): string | undefined {
2803
- if (!ts || typeof ts !== "string") return ts;
2804
- const m = ts.match(/^(.*\.)(\d+)(Z)$/);
2805
- if (!m) return ts;
2806
- const frac = m[2];
2807
- if (frac.length >= 9) return ts;
2808
- const padChar = mode === "before" ? "9" : "0";
2809
- return m[1] + frac + padChar.repeat(9 - frac.length) + m[3];
2810
- }
2968
+ // normalizeTimestampForCursor now lives in ./timestamps.ts (imported above),
2969
+ // where it also handles whole-second timestamps so lexical cursor comparison
2970
+ // matches chronological order.
2811
2971
 
2812
2972
  function normalizeChannelDocLevel(level: unknown): number | null {
2813
2973
  if (typeof level === "number" && Number.isInteger(level) && level >= 1 && level <= 4) {
@@ -2905,7 +3065,7 @@ function activateHiddenIdentityGame(gameId: string, channelId?: string) {
2905
3065
 
2906
3066
  async function fetchHiddenIdentityChannelId(gameId: string): Promise<string | undefined> {
2907
3067
  try {
2908
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(gameId)}`, {
3068
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(gameId)}`, {
2909
3069
  headers: { "Authorization": `Bearer ${TOKEN}` },
2910
3070
  });
2911
3071
  if (!r.ok) return undefined;
@@ -2951,31 +3111,22 @@ function clearFinishedHiddenIdentityGamesFromMessage(data: any) {
2951
3111
  // legitimately receive the same persisted message once via live WS and once
2952
3112
  // via REST backfill; timestamp guards alone would either fail to drop that
2953
3113
  // duplicate or drop older out-of-order messages that were never processed.
2954
- const deliveredMessageIds = new Set<string>();
2955
- const MAX_DELIVERED_MESSAGE_IDS = 5000;
3114
+ // Dedup core (key derivation + bounded set) lives in ./dedup.ts; this file
3115
+ // keeps the thin logging wrapper so call sites and stderr output are unchanged.
3116
+ const messageDedup = new MessageDedup();
2956
3117
  function deliverySource(data: any): string {
2957
3118
  return typeof data?.__source === "string" ? data.__source : "live";
2958
3119
  }
2959
- function messageDedupKey(data: any): string | null {
2960
- if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string") return null;
2961
- return `${data.channel_id}:${data.id}`;
2962
- }
2963
3120
  function recordOrSkipDeliveredMessage(data: any): boolean {
2964
3121
  const key = messageDedupKey(data);
2965
3122
  if (!key) return false;
2966
- if (deliveredMessageIds.has(key)) {
3123
+ const skip = messageDedup.recordOrSkip(key);
3124
+ if (skip) {
2967
3125
  process.stderr.write(
2968
3126
  `[agentchat] Duplicate message skipped source=${deliverySource(data)} chat=${String(data.channel_id).slice(0, 12)} id=${String(data.id).slice(0, 12)}\n`,
2969
3127
  );
2970
- return true;
2971
3128
  }
2972
- deliveredMessageIds.add(key);
2973
- if (deliveredMessageIds.size > MAX_DELIVERED_MESSAGE_IDS) {
2974
- const arr = [...deliveredMessageIds];
2975
- deliveredMessageIds.clear();
2976
- for (const item of arr.slice(1000)) deliveredMessageIds.add(item);
2977
- }
2978
- return false;
3129
+ return skip;
2979
3130
  }
2980
3131
  // Channels we believe we're a member of. Populated from
2981
3132
  // `channel_created` events on auth_ok. Used as the backfill target set.
@@ -2986,12 +3137,11 @@ const knownChannels = new Set<string>();
2986
3137
  let currentHandleWSMessage: ((data: any) => Promise<void>) | null = null;
2987
3138
  let wsReconnectAttempt = 0;
2988
3139
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2989
- /** Flipped true when the server sends shard_moved (planned drain).
2990
- * ws.onclose checks + clears it so the close is treated as a clean
2991
- * hop, not a failure that advances the exponential backoff. */
2992
- let isPlannedReconnect = false;
3140
+ // Tracked so shutdown / switchIdentity can cancel a pending auth_ok backfill.
3141
+ let backfillTimer: ReturnType<typeof setTimeout> | null = null;
2993
3142
 
2994
3143
  function scheduleReconnect(delayMs: number) {
3144
+ if (shuttingDown) return; // don't resurrect the socket after shutdown
2995
3145
  if (reconnectTimer) clearTimeout(reconnectTimer);
2996
3146
  reconnectTimer = setTimeout(() => {
2997
3147
  reconnectTimer = null;
@@ -3024,12 +3174,29 @@ async function backfillAllChannels(): Promise<void> {
3024
3174
  const after = lastSeenMessageTs.get(channelId);
3025
3175
  const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
3026
3176
  const url = `${REST_URL}/api/channels/${encodeURIComponent(channelId)}/messages${params}`;
3027
- const res = await fetch(url, {
3177
+ const res = await apiFetch(url, {
3028
3178
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
3029
3179
  });
3030
3180
  if (!res.ok) continue;
3031
3181
  const data = await res.json() as { messages?: any[] };
3032
3182
  const msgs = data.messages || [];
3183
+ // No cursor yet (cold start / freshly joined channel): DON'T replay —
3184
+ // seed the cursor from the newest message so later backfills fetch only
3185
+ // genuinely new messages. Replaying here would surface a possibly
3186
+ // hours-old DM/@mention as a live notification (contract: empty cursor
3187
+ // = no backfill).
3188
+ if (!after) {
3189
+ let newestTs = "";
3190
+ for (const m of msgs) {
3191
+ const t = String(m?.timestamp || "");
3192
+ if (t > newestTs) newestTs = t;
3193
+ }
3194
+ if (newestTs) {
3195
+ lastSeenMessageTs.set(channelId, newestTs);
3196
+ scheduleLastSeenMessageTsSave();
3197
+ }
3198
+ continue;
3199
+ }
3033
3200
  // Filter out self-messages upfront (faster than re-entering the
3034
3201
  // handler just to bail) and typing placeholders (plugin ignores
3035
3202
  // them anyway).
@@ -3064,17 +3231,29 @@ async function backfillAllChannels(): Promise<void> {
3064
3231
  }
3065
3232
 
3066
3233
  function connectWS() {
3234
+ // Don't spin up a new socket after stdio shutdown — a stray reconnect
3235
+ // timer must never resurrect the connection.
3236
+ if (shuttingDown) return;
3237
+
3238
+ // Capture THIS socket locally. Every handler below guards on
3239
+ // `ws !== socket`, so a superseded socket (e.g. a dead-TCP connection
3240
+ // whose close event is delayed past the point a replacement is already
3241
+ // live) can never clobber the current session or schedule a duplicate
3242
+ // reconnect — the root cause of the orphan/double-connection race.
3243
+ let socket: WebSocket;
3067
3244
  try {
3068
- ws = new WebSocket(WS_URL);
3245
+ socket = new WebSocket(WS_URL);
3246
+ ws = socket;
3069
3247
  } catch (e) {
3070
3248
  process.stderr.write(`[agentchat] WebSocket constructor failed: ${e}, retrying in 5s\n`);
3071
- setTimeout(connectWS, 5000);
3249
+ scheduleReconnect(5000); // route through the single tracked timer
3072
3250
  return;
3073
3251
  }
3074
3252
 
3075
3253
  ws.onopen = () => {
3254
+ if (ws !== socket) return; // superseded by a newer socket
3076
3255
  try {
3077
- ws!.send(JSON.stringify({
3256
+ socket.send(JSON.stringify({
3078
3257
  type: "auth",
3079
3258
  agent_id: AGENT_ID,
3080
3259
  token: TOKEN,
@@ -3086,6 +3265,7 @@ function connectWS() {
3086
3265
  };
3087
3266
 
3088
3267
  ws.onmessage = async (event) => {
3268
+ if (ws !== socket) return; // ignore late frames from a superseded socket
3089
3269
  let data: any;
3090
3270
  try { data = JSON.parse(String(event.data)); } catch { return; }
3091
3271
  if (data && typeof data === "object" && !data.__source) data.__source = "live";
@@ -3118,9 +3298,16 @@ function connectWS() {
3118
3298
  // per-channel REST fetch and re-injects each missed message
3119
3299
  // through handleWSMessage so @mention detection + notification
3120
3300
  // path is identical to live delivery — no divergent code paths.
3121
- setTimeout(() => { void backfillAllChannels(); }, 2000);
3301
+ if (backfillTimer) clearTimeout(backfillTimer);
3302
+ backfillTimer = setTimeout(() => {
3303
+ backfillTimer = null;
3304
+ if (!shuttingDown) void backfillAllChannels();
3305
+ }, 2000);
3122
3306
  } else if (
3123
- data.type === "message" &&
3307
+ // thread_reply is a bidirectional protocol frame (Server -> Client too).
3308
+ // Route it through the same @mention / notification / cursor path as a
3309
+ // plain message, otherwise thread @mentions are silently invisible.
3310
+ (data.type === "message" || data.type === "thread_reply") &&
3124
3311
  // Loop ticks are server-fired with sender_id=loop.agent_id, which
3125
3312
  // equals AGENT_ID when the loop owner is THIS plugin. Without this
3126
3313
  // exception the outer "skip own messages" gate swallows every tick
@@ -3175,36 +3362,19 @@ function connectWS() {
3175
3362
  }
3176
3363
 
3177
3364
  const isDM = data.channel_id?.startsWith("dm-");
3178
- // Match both `@<agentId>` and `@<displayName>(<agentId>)` formats.
3179
- // v0.6.1 used a loose `content.includes("(" + AGENT_ID + ")")` for the
3180
- // second case which fired on ANY text containing `(<agentId>)` —
3181
- // including system messages like "User joined: name (acc_xyz)" or
3182
- // moderation logs. Boss msg:fc8b9b1a — codex agent received messages
3183
- // it wasn't @-mentioned in, ate context window. Tighten the second
3184
- // clause to require an `@<displayName>` immediately before `(<id>)`.
3185
- const idEsc = (AGENT_ID || "").replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3186
- const displayMentionRe = idEsc ? new RegExp(`@[^(\\n]+\\(${idEsc}\\)`) : null;
3187
- const isMentioned = !!(
3188
- data.content?.includes(`@${AGENT_ID}`) ||
3189
- (displayMentionRe && displayMentionRe.test(data.content || ""))
3190
- );
3365
+ // Match `@<agentId>` and `@<displayName>(<agentId>)`; see matchesMention
3366
+ // (mentions.ts) for why the second clause must not fire on incidental
3367
+ // `(<id>)` substrings like "User joined: name (acc_xyz)".
3368
+ const isMentioned = matchesMention(data.content || "", AGENT_ID || "");
3191
3369
  const activeHi = activeHiddenIdentityForChannel(data.channel_id);
3192
3370
 
3193
3371
  if (isDM || isMentioned || activeHi) {
3194
- // DM or @mention → respond
3195
- // 立即发送 typing ACK
3196
- if (isDM || isMentioned) {
3197
- try {
3198
- if (ws && ws.readyState === WebSocket.OPEN) {
3199
- ws.send(JSON.stringify({
3200
- type: "message", id: crypto.randomUUID(),
3201
- channel_id: data.channel_id, sender_id: AGENT_ID,
3202
- sender_type: "agent", content: "__typing__",
3203
- content_type: "text", timestamp: new Date().toISOString(),
3204
- }));
3205
- }
3206
- } catch {}
3207
- }
3372
+ // DM or @mention → respond. Start a cross-pod typing heartbeat so a human
3373
+ // on any pod sees the agent "thinking" for the whole processing duration.
3374
+ // The hub now cross-pod-broadcasts agent typing frames (cross_pod:true)
3375
+ // and never persists them, so this is ephemeral + zero-write. Stops when
3376
+ // we reply to the channel (reply handler), at the 120s cap, or on shutdown.
3377
+ if (isDM || isMentioned) startTypingHeartbeat(data.channel_id);
3208
3378
 
3209
3379
  // For @mention in channels, fetch context since last mention
3210
3380
  let contextPrefix = "";
@@ -3222,7 +3392,7 @@ function connectWS() {
3222
3392
  // membership for private). Without the Bearer header the
3223
3393
  // MCP agent would get 401/403 and answer the @mention
3224
3394
  // without any conversation context.
3225
- const historyRes = await fetch(historyUrl, {
3395
+ const historyRes = await apiFetch(historyUrl, {
3226
3396
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
3227
3397
  });
3228
3398
  if (historyRes.ok) {
@@ -3243,7 +3413,7 @@ function connectWS() {
3243
3413
  const clipped = raw.length > maxPerMsg
3244
3414
  ? raw.slice(0, maxPerMsg) + " …[truncated]"
3245
3415
  : raw;
3246
- const size = clipped.length;
3416
+ const size = Buffer.byteLength(clipped, "utf8"); // real UTF-8 bytes (CJK is 3B/char, not 1)
3247
3417
  if (totalBytes + size > maxBytes) break;
3248
3418
  totalBytes += size;
3249
3419
  trimmed.unshift({ ...msgs[i], content: clipped });
@@ -3270,7 +3440,7 @@ function connectWS() {
3270
3440
  contextPrefix = `[HI游戏进行中 - 你是 game ${activeHi.gameId.slice(0, 8)} 的上桌玩家;此消息无需 @mention 也被实时推送。只在轮到你行动、需要讨论或需要投票时回复,否则可以旁观。]\n`;
3271
3441
  }
3272
3442
 
3273
- process.stderr.write(`[agentchat] ${isDM ? 'DM' : isMentioned ? '@mention' : 'HI-active'} from ${data.sender_id.slice(0, 8)}: ${data.content.slice(0, 50)}\n`);
3443
+ process.stderr.write(`[agentchat] ${isDM ? 'DM' : isMentioned ? '@mention' : 'HI-active'} from ${String(data.sender_id ?? "?").slice(0, 8)}: ${String(data.content ?? "").slice(0, 50)}\n`);
3274
3444
 
3275
3445
  // 推送给 Claude Code
3276
3446
  try {
@@ -3294,7 +3464,7 @@ function connectWS() {
3294
3464
  // Channel message without @mention → silent (just log)
3295
3465
  rateLimitedLog(
3296
3466
  "silent-channel-message",
3297
- `[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`,
3467
+ `[agentchat] [silent] ${String(data.sender_id ?? "?").slice(0, 8)} in ${String(data.channel_id ?? "?").slice(0, 12)}: ${String(data.content ?? "").slice(0, 30)}\n`,
3298
3468
  );
3299
3469
  }
3300
3470
  } else if (data.type === "channel_created") {
@@ -3310,25 +3480,21 @@ function connectWS() {
3310
3480
  // Task #119: track channel id for reconnect backfill target set.
3311
3481
  if (typeof data.channel_id === "string") knownChannels.add(data.channel_id);
3312
3482
  } else if (data.type === "shard_moved") {
3313
- // Server instance shutting down or channel moved — reconnect
3314
- // immediately. This is a PLANNED disconnect: the server is
3315
- // giving us heads-up to move. Mark it so the upcoming
3316
- // ws.onclose doesn't treat this as a failure that increments
3317
- // wsReconnectAttempt / stretches the exponential backoff. When
3318
- // the server does a rolling deploy (several pods closing in
3319
- // sequence), without this flag each shard_moved would push the
3320
- // next reconnect 2s, 4s, 6s ... further out even though each
3321
- // is a clean planned event.
3483
+ // Server instance shutting down or channel moved — a PLANNED hop.
3484
+ // Detach the socket's onclose so it can't also run the unplanned
3485
+ // backoff path, reset the backoff counter, and fast-reconnect. This
3486
+ // keeps a rolling deploy (several pods closing in sequence) from
3487
+ // pushing each successive reconnect 2s, 4s, 6s ... further out.
3322
3488
  process.stderr.write(`[agentchat] Shard moved, reconnecting...\n`);
3323
3489
  if (data.redirect_url) {
3324
3490
  const newUrl = data.redirect_url.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
3325
3491
  process.stderr.write(`[agentchat] Redirecting to: ${newUrl}\n`);
3326
3492
  // Note: for simplicity we reconnect to original URL and let /api/shard handle routing
3327
3493
  }
3328
- isPlannedReconnect = true;
3329
- try { ws?.close(); } catch {}
3494
+ if (ws) { ws.onclose = null; try { ws.close(); } catch {} }
3330
3495
  ws = null;
3331
3496
  sessionId = null;
3497
+ wsReconnectAttempt = 0;
3332
3498
  scheduleReconnect(500);
3333
3499
  } else if (data.type === "error") {
3334
3500
  process.stderr.write(`[agentchat] Error: ${data.message}\n`);
@@ -3336,26 +3502,21 @@ function connectWS() {
3336
3502
  }
3337
3503
 
3338
3504
  ws.onclose = (event) => {
3505
+ if (ws !== socket) return; // a superseded socket's delayed close — leave live state alone
3339
3506
  sessionId = null;
3340
3507
  heartbeat.resetReconnecting(); // allow heartbeat to reconnect again if needed
3341
- // Planned reconnect (server-initiated shard_moved): reset backoff
3342
- // and reconnect fast, don't count this against exponential delay.
3343
- if (isPlannedReconnect) {
3344
- isPlannedReconnect = false;
3345
- wsReconnectAttempt = 0;
3346
- // A concurrent scheduleReconnect(500) from the shard_moved
3347
- // handler has already been queued — don't double-schedule.
3348
- process.stderr.write(`[agentchat] Planned close (code=${(event as any)?.code ?? "?"}), reconnect in 0.5s\n`);
3349
- return;
3350
- }
3508
+ // Only genuine UNPLANNED drops reach here now. Planned paths
3509
+ // (shard_moved / heartbeat.reconnect / switchIdentity) detach this
3510
+ // handler and self-schedule a fast reconnect, so there is no
3511
+ // isPlannedReconnect flag to consume and no fast/slow ambiguity.
3351
3512
  wsReconnectAttempt++;
3352
- const jitter = Math.random() * 3000; // 0-3s random jitter to avoid thundering herd
3353
- const delay = Math.min(wsReconnectAttempt * 2, 30) * 1000 + jitter;
3513
+ const delay = computeReconnectDelay(wsReconnectAttempt);
3354
3514
  process.stderr.write(`[agentchat] Disconnected (code=${(event as any)?.code ?? "?"}), reconnecting in ${Math.round(delay/100)/10}s (attempt ${wsReconnectAttempt})...\n`);
3355
3515
  scheduleReconnect(delay);
3356
3516
  };
3357
3517
 
3358
3518
  ws.onerror = (err) => {
3519
+ if (ws !== socket) return;
3359
3520
  process.stderr.write(`[agentchat] WebSocket error: ${err}\n`);
3360
3521
  };
3361
3522
  }
@@ -3369,7 +3530,9 @@ const heartbeat = new HeartbeatMonitor({
3369
3530
  },
3370
3531
  reconnect: () => {
3371
3532
  process.stderr.write("[agentchat] Heartbeat timeout, forcing reconnect\n");
3372
- try { ws?.close(); } catch {}
3533
+ // Detach onclose so the forced close doesn't also run the unplanned
3534
+ // backoff path and downgrade this fast 500ms recovery to 2-5s.
3535
+ if (ws) { ws.onclose = null; try { ws.close(); } catch {} }
3373
3536
  ws = null;
3374
3537
  sessionId = null;
3375
3538
  wsReconnectAttempt = 0; // reset backoff for heartbeat-triggered reconnect
@@ -3385,10 +3548,15 @@ function shutdownFromStdio(reason: string) {
3385
3548
  safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3386
3549
  try { flushLastSeenMessageTs(); } catch {}
3387
3550
  try { heartbeat.stop(); } catch {}
3551
+ try { stopAllTypingHeartbeats(); } catch {}
3388
3552
  if (reconnectTimer) {
3389
3553
  clearTimeout(reconnectTimer);
3390
3554
  reconnectTimer = null;
3391
3555
  }
3556
+ if (backfillTimer) {
3557
+ clearTimeout(backfillTimer);
3558
+ backfillTimer = null;
3559
+ }
3392
3560
  try { ws?.close(); } catch {}
3393
3561
  ws = null;
3394
3562
  sessionId = null;