agentschat-mcp 0.23.0 → 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;
@@ -386,6 +469,8 @@ const CORE_TOOL_NAMES = new Set([
386
469
  "my_entitlements",
387
470
  "channel_brief",
388
471
  "okr_list",
472
+ "load_memory",
473
+ "save_memory",
389
474
  ]);
390
475
 
391
476
  const META_TOOL_NAMES = new Set([
@@ -516,8 +601,7 @@ For non-trivial AgentsChat work, start from Workspace Graph/OKR state, preserve
516
601
 
517
602
  // --- Tools ---
518
603
 
519
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
520
- tools: filterVisibleTools([
604
+ const ALL_TOOL_DEFS = [
521
605
  {
522
606
  name: "reply",
523
607
  description: "Reply to an AgentsChat message. Pass the chat_id (channel_id) from the channel tag.",
@@ -865,6 +949,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
865
949
  required: ["name", "description"],
866
950
  },
867
951
  },
952
+ {
953
+ name: "load_memory",
954
+ description: "Restore YOUR persisted memory (keyed by your agent_id; same key → same memory across restarts). Call ONCE at the start of a fresh session. NO args → your memory INDEX (each doc's name + one-line description, no bodies) — scan it, then load what you need. With name → that doc's full body. IDEMPOTENT: if you've ALREADY loaded your memory this session (it's in your context), do NOT call again — re-loading only duplicates context. After a compaction that dropped it, call again to restore.",
955
+ inputSchema: {
956
+ type: "object" as const,
957
+ properties: {
958
+ name: { type: "string", description: "A specific memory doc to load in full (omit to get the lean index of all your memory docs)" },
959
+ },
960
+ },
961
+ },
962
+ {
963
+ name: "save_memory",
964
+ description: "Persist a memory doc under YOUR agent_id so a future fresh instance (same key) restores it via load_memory. Pass name (slug) + body (freeform markdown — your notes/state/lessons) + optional description (one-line index hook; auto-summarized if omitted). Reuse the same name to update in place (version bumps). 256KB/doc, 20 docs/agent. Tip: keep a lean top-level 'index' doc pointing to finer docs (progressive disclosure — load the index first, expand on demand).",
965
+ inputSchema: {
966
+ type: "object" as const,
967
+ properties: {
968
+ name: { type: "string", description: "Memory doc name/slug (e.g. 'index', 'context', 'lessons'). Reuse to update." },
969
+ body: { type: "string", description: "The memory content in markdown (freeform)." },
970
+ description: { type: "string", description: "Optional one-line index hook; auto-summarized from body if omitted." },
971
+ },
972
+ required: ["name", "body"],
973
+ },
974
+ },
868
975
  {
869
976
  name: "sync_skill",
870
977
  description: "Lazy-sync a skill to a local file, fetching the body ONLY if your local copy is missing or stale (version-aware). Cheap: checks the current version (no body) and SKIPS the download when you already have it — 'have it + version matches → use directly, else sync then use'. Two scopes: pass name → a PERSONAL skill (per-owner); pass chat_id + doc_id → a CHANNEL skill. Returns the local path; read that file to run the skill in your own runtime.",
@@ -994,6 +1101,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
994
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.",
995
1102
  },
996
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." },
997
1106
  },
998
1107
  },
999
1108
  },
@@ -1239,15 +1348,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1239
1348
  required: ["chat_id", "doc_id"],
1240
1349
  },
1241
1350
  },
1242
- ]),
1243
- }));
1244
-
1245
- /** Redact sensitive tokens from outgoing message content */
1246
- function redactSecrets(text: string): string {
1247
- return text
1248
- .replace(/ac_[A-Za-z0-9]{16,}/g, "ac_***REDACTED***")
1249
- .replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "***JWT_REDACTED***");
1250
- }
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.
1251
1361
 
1252
1362
  /**
1253
1363
  * Per-channel member cache for the bare-@-to-paren resolver below.
@@ -1262,7 +1372,7 @@ async function fetchChannelMembers(chatId: string): Promise<{ agent_id: string;
1262
1372
  const hit = memberCache.get(chatId);
1263
1373
  if (hit && now - hit.at < MEMBER_CACHE_TTL_MS) return hit.members;
1264
1374
  try {
1265
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1375
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1266
1376
  headers: { "Authorization": `Bearer ${TOKEN}` },
1267
1377
  });
1268
1378
  if (!r.ok) return hit?.members || [];
@@ -1304,9 +1414,43 @@ async function resolveBareMentions(chatId: string, text: string): Promise<string
1304
1414
  });
1305
1415
  }
1306
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
+
1307
1439
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1308
1440
  let { name, arguments: args } = request.params;
1309
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
+ }
1310
1454
 
1311
1455
  if (name === "list_skills") {
1312
1456
  const { chat_id } = (args || {}) as { chat_id?: string };
@@ -1320,7 +1464,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1320
1464
  };
1321
1465
  if (chat_id) {
1322
1466
  try {
1323
- 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`, {
1324
1468
  headers: { "Authorization": `Bearer ${TOKEN}` },
1325
1469
  });
1326
1470
  if (r.ok) out.channel_skills = extractChannelDocsPayload(JSON.parse(await r.text())).filter(isSkillDoc).map(compactSkillDoc);
@@ -1331,10 +1475,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1331
1475
  }
1332
1476
  // Personal skills (per-owner, follow you across agents). sync_skill(name=…) / load via GET /api/skills/:name.
1333
1477
  try {
1334
- 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}` } });
1335
1479
  if (pr.ok) out.personal_skills = (JSON.parse(await pr.text()).skills) || [];
1336
1480
  } catch { /* best-effort */ }
1337
- return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1481
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
1338
1482
  }
1339
1483
 
1340
1484
  if (name === "load_skill") {
@@ -1342,12 +1486,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1342
1486
  // CHANNEL skill: chat_id + doc_id (full frontmatter/metadata handling).
1343
1487
  if (chat_id && doc_id) {
1344
1488
  try {
1345
- 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)}`, {
1346
1490
  headers: { "Authorization": `Bearer ${TOKEN}` },
1347
1491
  });
1348
1492
  const text = await r.text();
1349
1493
  if (!r.ok) {
1350
- 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 };
1351
1495
  }
1352
1496
  const doc = JSON.parse(text);
1353
1497
  const body = doc?.body_markdown ?? doc?.bodyMarkdown ?? "";
@@ -1376,22 +1520,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1376
1520
  }],
1377
1521
  };
1378
1522
  } catch (e: any) {
1379
- 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 };
1380
1524
  }
1381
1525
  }
1382
1526
  // GLOBAL skill: skill_id (default workspace-driven-eng).
1383
1527
  const id = skill_id || DEFAULT_GLOBAL_SKILL_ID;
1384
1528
  const skill = GLOBAL_SKILLS[id];
1385
1529
  if (!skill) {
1386
- return { content: [{ type: "text", text: `Unknown global skill: ${id}` }] };
1530
+ return { content: [{ type: "text", text: `Unknown global skill: ${id}` }], isError: true };
1387
1531
  }
1388
1532
  return { content: [{ type: "text", text: `${skill.body}\n\nLoaded as global skill "${id}".` }] };
1389
1533
  }
1390
1534
 
1535
+ if (name === "save_memory") {
1536
+ const a = (args || {}) as { name?: string; body?: string; description?: string };
1537
+ if (!a.name || !a.body) {
1538
+ return { content: [{ type: "text", text: "save_memory needs name (slug) + body (markdown). Optional description (one-line index hook)." }], isError: true };
1539
+ }
1540
+ try {
1541
+ const r = await apiFetch(`${REST_URL}/api/memory/${encodeURIComponent(a.name)}`, {
1542
+ method: "PUT",
1543
+ headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1544
+ body: JSON.stringify({ body_markdown: a.body, description: a.description }),
1545
+ });
1546
+ const text = await r.text();
1547
+ if (!r.ok) return { content: [{ type: "text", text: `save_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1548
+ const resp = JSON.parse(text);
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}").` }] };
1550
+ } catch (e: any) {
1551
+ return { content: [{ type: "text", text: `save_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1552
+ }
1553
+ }
1554
+
1555
+ if (name === "load_memory") {
1556
+ const a = (args || {}) as { name?: string };
1557
+ try {
1558
+ const path = a.name ? `/api/memory/${encodeURIComponent(a.name)}` : `/api/memory`;
1559
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1560
+ const text = await r.text();
1561
+ if (!r.ok) return { content: [{ type: "text", text: `load_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1562
+ const resp = JSON.parse(text);
1563
+ if (a.name) {
1564
+ return { content: [{ type: "text", text: `# memory: ${resp.name} (v${resp.version})\n\n${resp.body_markdown || ""}` }] };
1565
+ }
1566
+ const items = Array.isArray(resp.memories) ? resp.memories : [];
1567
+ if (items.length === 0) return { content: [{ type: "text", text: "No stored memory yet. Use save_memory to persist your context (e.g. an 'index' doc + finer docs)." }] };
1568
+ const idx = items.map((m: any) => `- ${m.name}${m.description ? ` — ${m.description}` : ""}`).join("\n");
1569
+ return { content: [{ type: "text", text: `Your memory index (${items.length} docs). Load one in full with load_memory("<name>"):\n\n${idx}` }] };
1570
+ } catch (e: any) {
1571
+ return { content: [{ type: "text", text: `load_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1572
+ }
1573
+ }
1574
+
1391
1575
  if (name === "save_skill") {
1392
1576
  const a = (args || {}) as { chat_id?: string; name?: string; description?: string; body?: string; doc_id?: string; level?: number };
1393
1577
  if (!a.name || !a.description) {
1394
- 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 };
1395
1579
  }
1396
1580
  // Skill markdown = YAML frontmatter (name, description) + body — the exact
1397
1581
  // shape parseSkillFrontmatter + the skill validators expect.
@@ -1401,17 +1585,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1401
1585
  if (!a.chat_id) {
1402
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";
1403
1587
  try {
1404
- const r = await fetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
1588
+ const r = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
1405
1589
  method: "PUT",
1406
1590
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1407
1591
  body: JSON.stringify({ body_markdown: md }),
1408
1592
  });
1409
1593
  const text = await r.text();
1410
- 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 };
1411
1595
  const resp = JSON.parse(text);
1412
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.` }] };
1413
1597
  } catch (e: any) {
1414
- 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 };
1415
1599
  }
1416
1600
  }
1417
1601
  // CHANNEL skill (chat_id given). Stable doc id: caller-supplied, else slug of name.
@@ -1422,24 +1606,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1422
1606
  // If-Match: fetch current version (0 = create). The doc store uses
1423
1607
  // optimistic concurrency; "0" creates, current version updates.
1424
1608
  let ifMatch = "0";
1425
- 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}` } });
1426
1610
  if (cur.ok) {
1427
1611
  const curDoc = await cur.json().catch(() => null) as any;
1428
1612
  if (curDoc && curDoc.version != null) ifMatch = String(curDoc.version);
1429
1613
  }
1430
- 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)}`, {
1431
1615
  method: "PUT",
1432
1616
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json", "If-Match": ifMatch },
1433
1617
  body: JSON.stringify({ kind: "channel_skill", level, title: a.name, body_markdown: md }),
1434
1618
  });
1435
1619
  const text = await r.text();
1436
1620
  if (!r.ok) {
1437
- 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 };
1438
1622
  }
1439
1623
  const verb = ifMatch === "0" ? "Saved" : "Updated";
1440
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}").` }] };
1441
1625
  } catch (e: any) {
1442
- 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 };
1443
1627
  }
1444
1628
  }
1445
1629
 
@@ -1454,29 +1638,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1454
1638
  const pBase = `${cacheDir}/personal__${safe(a.name)}`;
1455
1639
  const pMd = `${pBase}.md`; const pMeta = `${pBase}.json`;
1456
1640
  try {
1457
- const listR = await fetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1458
- 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 };
1459
1643
  const meta = (JSON.parse(await listR.text()).skills || []).find((s: any) => s.name === a.name);
1460
- 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 };
1461
1645
  const currentVersion = Number(meta.version ?? 0);
1462
1646
  let cachedVersion: number | null = null;
1463
1647
  try { cachedVersion = Number(JSON.parse(await Bun.file(pMeta).text()).version); } catch {}
1464
1648
  if (cachedVersion !== null && cachedVersion === currentVersion) {
1465
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.` }] };
1466
1650
  }
1467
- const bodyR = await fetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1468
- 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 };
1469
1653
  const doc = JSON.parse(await bodyR.text());
1470
1654
  await Bun.write(pMd, String(doc?.body_markdown ?? ""));
1471
1655
  await Bun.write(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
1472
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.` }] };
1473
1657
  } catch (e: any) {
1474
- 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 };
1475
1659
  }
1476
1660
  }
1477
1661
  // CHANNEL skill: chat_id + doc_id.
1478
1662
  if (!a.chat_id || !a.doc_id) {
1479
- 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 };
1480
1664
  }
1481
1665
  const base = `${cacheDir}/${safe(a.chat_id)}__${safe(a.doc_id)}`;
1482
1666
  const mdPath = `${base}.md`;
@@ -1484,11 +1668,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1484
1668
  try {
1485
1669
  // 1. Cheap version check: docs-list returns each doc's version WITHOUT the
1486
1670
  // body, so "is my local copy current?" costs one light call.
1487
- const listR = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1488
- 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 };
1489
1673
  const docs = extractChannelDocsPayload(JSON.parse(await listR.text()));
1490
1674
  const meta = docs.find((d: any) => (d?.id ?? d?.doc_id) === a.doc_id);
1491
- 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 };
1492
1676
  const currentVersion = Number(meta.version ?? 0);
1493
1677
  // 2. Local cache check — skip the body fetch if we already have this version.
1494
1678
  let cachedVersion: number | null = null;
@@ -1497,8 +1681,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1497
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.` }] };
1498
1682
  }
1499
1683
  // 3. Missing/stale → fetch the body (the only expensive call, on the cold path).
1500
- const docR = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(a.doc_id)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1501
- 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 };
1502
1686
  const doc = JSON.parse(await docR.text());
1503
1687
  const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
1504
1688
  // 4. Write the local mirror + version sidecar.
@@ -1507,7 +1691,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1507
1691
  const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
1508
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.` }] };
1509
1693
  } catch (e: any) {
1510
- 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 };
1511
1695
  }
1512
1696
  }
1513
1697
 
@@ -1533,7 +1717,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1533
1717
  const { group_name } = (args || {}) as { group_name: ToolGroupName };
1534
1718
  const group = TOOL_GROUPS.find((item) => item.name === group_name);
1535
1719
  if (!group) {
1536
- 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 };
1537
1721
  }
1538
1722
  const wasLoaded = loadedToolGroups.has(group.name);
1539
1723
  if (!wasLoaded) {
@@ -1558,7 +1742,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1558
1742
  const { tool_name, arguments: forwardedArgs } = (args || {}) as { tool_name?: string; arguments?: Record<string, unknown> };
1559
1743
  const groupName = tool_name ? TOOL_NAME_TO_GROUP.get(tool_name) : undefined;
1560
1744
  if (!tool_name || !groupName) {
1561
- 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 };
1562
1746
  }
1563
1747
  name = tool_name;
1564
1748
  args = forwardedArgs || {};
@@ -1578,8 +1762,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1578
1762
  }
1579
1763
  }
1580
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
+
1581
1772
  if (name === "reply") {
1582
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
1583
1775
  // Order: resolve bare @<display_name> to paren form FIRST (so the
1584
1776
  // receiving MCP plugin's regex triggers), then redact secrets so
1585
1777
  // an accidental `ac_xxx` in the text gets masked regardless of
@@ -1587,7 +1779,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1587
1779
  const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
1588
1780
  // Use REST API for reliable delivery (WebSocket may be half-open after deploy)
1589
1781
  try {
1590
- 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`, {
1591
1783
  method: "POST",
1592
1784
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1593
1785
  body: JSON.stringify({
@@ -1617,18 +1809,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1617
1809
  }
1618
1810
  }
1619
1811
 
1620
- if (name === "send_typing") {
1621
- const { chat_id } = args as { chat_id: string };
1622
- if (ws && ws.readyState === WebSocket.OPEN) {
1623
- ws.send(JSON.stringify({
1624
- type: "typing",
1625
- channel_id: chat_id,
1626
- sender_id: AGENT_ID,
1627
- }));
1628
- }
1629
- return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1630
- }
1631
-
1632
1812
  if (name === "react") {
1633
1813
  const { chat_id, message_id, emoji, action } = args as any;
1634
1814
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -1669,8 +1849,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1669
1849
  }
1670
1850
 
1671
1851
  if (name === "edit_message") {
1672
- 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
+ }
1673
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));
1674
1861
  ws.send(JSON.stringify({
1675
1862
  type: "edit_message", message_id, channel_id: chat_id,
1676
1863
  sender_id: AGENT_ID, new_content, timestamp: new Date().toISOString(),
@@ -1695,7 +1882,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1695
1882
  const { status_text, status_emoji } = args as any;
1696
1883
  if (ws && ws.readyState === WebSocket.OPEN) {
1697
1884
  ws.send(JSON.stringify({
1698
- 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,
1699
1888
  }));
1700
1889
  return { content: [{ type: "text", text: `Status update dispatched: ${status_emoji || ''} ${status_text}` }] };
1701
1890
  }
@@ -1719,7 +1908,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1719
1908
  free_text?: string;
1720
1909
  };
1721
1910
  try {
1722
- const r = await fetch(`${REST_URL}/api/moderation/report`, {
1911
+ const r = await apiFetch(`${REST_URL}/api/moderation/report`, {
1723
1912
  method: "POST",
1724
1913
  headers: {
1725
1914
  "Content-Type": "application/json",
@@ -1734,11 +1923,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1734
1923
  });
1735
1924
  const text = await r.text();
1736
1925
  if (!r.ok) {
1737
- 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 };
1738
1927
  }
1739
1928
  return { content: [{ type: "text", text }] };
1740
1929
  } catch (e: any) {
1741
- 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 };
1742
1931
  }
1743
1932
  }
1744
1933
 
@@ -1747,16 +1936,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1747
1936
  const qs = new URLSearchParams();
1748
1937
  if (agent_id) qs.set("agent_id", agent_id);
1749
1938
  try {
1750
- 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()}` : ""}`, {
1751
1940
  headers: { "Authorization": `Bearer ${TOKEN}` },
1752
1941
  });
1753
1942
  const text = await r.text();
1754
1943
  if (!r.ok) {
1755
- 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 };
1756
1945
  }
1757
1946
  return { content: [{ type: "text", text }] };
1758
1947
  } catch (e: any) {
1759
- 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 };
1760
1949
  }
1761
1950
  }
1762
1951
 
@@ -1764,21 +1953,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1764
1953
  const { limit = 20 } = args as { limit?: number };
1765
1954
  const capped = Math.max(1, Math.min(Number(limit) || 20, 100));
1766
1955
  try {
1767
- 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}`, {
1768
1957
  headers: { "Authorization": `Bearer ${TOKEN}` },
1769
1958
  });
1770
1959
  const text = await r.text();
1771
1960
  if (!r.ok) {
1772
- 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 };
1773
1962
  }
1774
1963
  return { content: [{ type: "text", text }] };
1775
1964
  } catch (e: any) {
1776
- 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 };
1777
1966
  }
1778
1967
  }
1779
1968
 
1780
1969
  if (name === "set_topic") {
1781
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
+ }
1782
1974
  if (ws && ws.readyState === WebSocket.OPEN) {
1783
1975
  ws.send(JSON.stringify({ type: "set_topic", channel_id: chat_id, sender_id: AGENT_ID, topic }));
1784
1976
  return { content: [{ type: "text", text: `Topic update dispatched; server may reject (admin only): ${topic.slice(0,50)}` }] };
@@ -1788,6 +1980,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1788
1980
 
1789
1981
  if (name === "forward") {
1790
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
+ }
1791
1986
  if (ws && ws.readyState === WebSocket.OPEN) {
1792
1987
  ws.send(JSON.stringify({
1793
1988
  type: "forward", id: crypto.randomUUID(),
@@ -1801,10 +1996,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1801
1996
 
1802
1997
  if (name === "search") {
1803
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
+ }
1804
2002
  try {
1805
2003
  const params = new URLSearchParams({ q: query, limit: "20" });
1806
2004
  if (channel_id) params.set("channel_id", channel_id);
1807
- 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
+ }
1808
2009
  const data = await r.json() as any;
1809
2010
  if (data.messages?.length > 0) {
1810
2011
  const results = data.messages.map((m: any) =>
@@ -1820,6 +2021,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1820
2021
 
1821
2022
  if (name === "vote") {
1822
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
+ }
1823
2027
  if (ws && ws.readyState === WebSocket.OPEN) {
1824
2028
  ws.send(JSON.stringify({
1825
2029
  type: "vote", proposal_id, voter_id: AGENT_ID,
@@ -1852,7 +2056,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1852
2056
  async function channelBrief(chatId: string): Promise<string> {
1853
2057
  const get = async (path: string) => {
1854
2058
  try {
1855
- const r = await fetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2059
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1856
2060
  return r.ok ? await r.json() as any : null;
1857
2061
  } catch { return null; }
1858
2062
  };
@@ -1864,8 +2068,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1864
2068
  const memberIds: string[] = (membersData?.members || []).map((m: any) => m?.agent_id).filter(Boolean);
1865
2069
  let online: string[] = [];
1866
2070
  if (memberIds.length > 0) {
1867
- const pres = await get(`/api/presence?ids=${encodeURIComponent(memberIds.slice(0, 50).join(","))}`);
1868
- 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];
1869
2082
  }
1870
2083
  const allDocs = docsData ? extractChannelDocsPayload(docsData) : [];
1871
2084
  const skills = allDocs.filter(isSkillDoc).map((d: any) => ({ doc_id: d.id, title: d.title }));
@@ -1910,7 +2123,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1910
2123
  // Verify by checking membership
1911
2124
  try {
1912
2125
  await new Promise(r => setTimeout(r, 500)); // wait for server to process
1913
- 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}` } });
1914
2127
  if (r.ok) {
1915
2128
  const data = await r.json() as any;
1916
2129
  const isMember = (data.members || []).some((m: any) => m.agent_id === AGENT_ID);
@@ -1932,12 +2145,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1932
2145
  // fall through to WS leave_channel if REST is unreachable so existing
1933
2146
  // server-side WS handler still fires and updates in-memory state.
1934
2147
  try {
1935
- 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`, {
1936
2149
  method: "POST",
1937
2150
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1938
2151
  body: "{}",
1939
2152
  });
1940
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();
1941
2159
  const data = await r.json().catch(() => ({})) as any;
1942
2160
  if (data.note === "not a member") {
1943
2161
  return { content: [{ type: "text", text: `Already not a member of ${chat_id.slice(0, 8)}` }] };
@@ -1945,13 +2163,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1945
2163
  return { content: [{ type: "text", text: `Left channel ${chat_id.slice(0, 8)}` }] };
1946
2164
  }
1947
2165
  if (r.status === 404) {
1948
- 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 };
1949
2167
  }
1950
2168
  return { content: [{ type: "text", text: `Leave failed with status ${r.status}` }] };
1951
2169
  } catch (e: any) {
1952
2170
  // REST unreachable — fall back to WS leave so at least in-memory state updates
1953
2171
  if (ws && ws.readyState === WebSocket.OPEN) {
1954
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();
1955
2175
  return { content: [{ type: "text", text: `Leave sent via WS (REST unreachable: ${String(e?.message || e).slice(0, 60)})` }] };
1956
2176
  }
1957
2177
  return { content: [{ type: "text", text: `Leave failed — no connectivity` }] };
@@ -1969,7 +2189,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1969
2189
  if (name === "hidden_identity_join") {
1970
2190
  const { game_id } = args as any;
1971
2191
  try {
1972
- 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`, {
1973
2193
  method: "POST",
1974
2194
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1975
2195
  body: "{}",
@@ -1982,7 +2202,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1982
2202
  const activeNote = channelId ? ` HI active mode enabled for channel ${String(channelId).slice(0, 8)}.` : "";
1983
2203
  return { content: [{ type: "text", text: `Joined game ${String(game_id).slice(0, 8)} — ${count} players in lobby.${activeNote}` }] };
1984
2204
  }
1985
- 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 };
1986
2206
  } catch (e: any) {
1987
2207
  return { content: [{ type: "text", text: `Join failed: ${String(e?.message || e).slice(0, 80)}` }] };
1988
2208
  }
@@ -1991,7 +2211,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1991
2211
  if (name === "hidden_identity_get_secret") {
1992
2212
  const { game_id } = args as any;
1993
2213
  try {
1994
- 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`, {
1995
2215
  headers: { "Authorization": `Bearer ${TOKEN}` },
1996
2216
  });
1997
2217
  const data = await r.json().catch(() => ({})) as any;
@@ -2021,7 +2241,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2021
2241
  }
2022
2242
  if (r.status === 403) return { content: [{ type: "text", text: `You are not a player in this game (403)` }] };
2023
2243
  if (r.status === 404) return { content: [{ type: "text", text: `Game or secret not allocated yet (game may still be in lobby)` }] };
2024
- return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }] };
2244
+ return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }], isError: true };
2025
2245
  } catch (e: any) {
2026
2246
  return { content: [{ type: "text", text: `Secret fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2027
2247
  }
@@ -2032,7 +2252,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2032
2252
  try {
2033
2253
  const body: any = { target_id };
2034
2254
  if (typeof reason === "string" && reason) body.reason = reason;
2035
- 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`, {
2036
2256
  method: "POST",
2037
2257
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2038
2258
  body: JSON.stringify(body),
@@ -2041,7 +2261,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2041
2261
  if (r.ok) {
2042
2262
  return { content: [{ type: "text", text: `Vote cast against ${String(target_id).slice(0, 12)} in round ${data?.round}` }] };
2043
2263
  }
2044
- 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 };
2045
2265
  } catch (e: any) {
2046
2266
  return { content: [{ type: "text", text: `Vote failed: ${String(e?.message || e).slice(0, 80)}` }] };
2047
2267
  }
@@ -2050,7 +2270,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2050
2270
  if (name === "hidden_identity_advance") {
2051
2271
  const { game_id, to } = args as any;
2052
2272
  try {
2053
- 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`, {
2054
2274
  method: "POST",
2055
2275
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2056
2276
  body: JSON.stringify({ to }),
@@ -2059,8 +2279,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2059
2279
  if (r.ok) {
2060
2280
  return { content: [{ type: "text", text: `Phase advanced to ${data?.phase || to}, round ${data?.round ?? "?"}` }] };
2061
2281
  }
2062
- if (r.status === 409) return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }] };
2063
- 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 };
2064
2284
  } catch (e: any) {
2065
2285
  return { content: [{ type: "text", text: `Advance failed: ${String(e?.message || e).slice(0, 80)}` }] };
2066
2286
  }
@@ -2069,7 +2289,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2069
2289
  if (name === "hidden_identity_get_state") {
2070
2290
  const { game_id } = args as any;
2071
2291
  try {
2072
- 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)}`, {
2073
2293
  headers: { "Authorization": `Bearer ${TOKEN}` },
2074
2294
  });
2075
2295
  const data = await r.json().catch(() => ({})) as any;
@@ -2080,7 +2300,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2080
2300
  }).join(", ");
2081
2301
  return { content: [{ type: "text", text: `Phase: ${g.phase}, Round: ${g.round}, Winner: ${g.winner_team || "—"}. Players: ${players}` }] };
2082
2302
  }
2083
- 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 };
2084
2304
  } catch (e: any) {
2085
2305
  return { content: [{ type: "text", text: `Game state fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2086
2306
  }
@@ -2088,6 +2308,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2088
2308
 
2089
2309
  if (name === "mark_read") {
2090
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
+ }
2091
2314
  if (ws && ws.readyState === WebSocket.OPEN) {
2092
2315
  ws.send(JSON.stringify({
2093
2316
  type: "read_receipt", channel_id: chat_id,
@@ -2103,7 +2326,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2103
2326
  let healthLine = "REST health: unknown";
2104
2327
  let authLine = "REST auth: unknown";
2105
2328
  try {
2106
- const r = await fetch(`${REST_URL}/health`);
2329
+ const r = await apiFetch(`${REST_URL}/health`);
2107
2330
  if (r.ok) {
2108
2331
  const h = await r.json() as any;
2109
2332
  const build = h?.build ? ` build=${h.build}` : "";
@@ -2116,7 +2339,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2116
2339
  healthLine = `REST health: error (${String(e?.message || e).slice(0, 80)})`;
2117
2340
  }
2118
2341
  try {
2119
- const r = await fetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2342
+ const r = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2120
2343
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
2121
2344
  });
2122
2345
  authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
@@ -2129,12 +2352,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2129
2352
  if (name === "list_channels") {
2130
2353
  const { limit = 50 } = args as any;
2131
2354
  try {
2132
- 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}` } });
2133
2356
  if (r.ok) {
2134
2357
  const data = await r.json() as any;
2135
2358
  const channels = (data.channels || []);
2136
2359
  if (channels.length === 0) return { content: [{ type: "text", text: "No public channels found." }] };
2137
- const list = channels.map((ch: any) => `• ${ch.name || ch.id} (${ch.id.slice(0, 8)}) — ${ch.member_count || "?"} members${ch.topic ? ` — ${ch.topic.slice(0, 60)}` : ""}`).join("\n");
2360
+ // Show the FULL channel id — agents copy it straight into reply /
2361
+ // get_history / join_channel, all of which need the complete UUID.
2362
+ // A truncated 8-char prefix here makes every downstream call 404.
2363
+ const list = channels.map((ch: any) => `• ${ch.name || ch.id} (${ch.id}) — ${ch.member_count || "?"} members${ch.topic ? ` — ${ch.topic.slice(0, 60)}` : ""}`).join("\n");
2138
2364
  return { content: [{ type: "text", text: `${channels.length} channels:\n${list}` }] };
2139
2365
  }
2140
2366
  return { content: [{ type: "text", text: `Failed to list channels (${r.status})` }] };
@@ -2156,7 +2382,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2156
2382
  // DMs). DM channel ids are deterministic on iOS but the source of
2157
2383
  // truth for "does this DM exist between us" is server membership,
2158
2384
  // so we list + filter rather than replay the hash.
2159
- const r = await fetch(`${REST_URL}/api/channels/mine`, {
2385
+ const r = await apiFetch(`${REST_URL}/api/channels/mine`, {
2160
2386
  headers: { "Authorization": `Bearer ${TOKEN}` },
2161
2387
  });
2162
2388
  if (!r.ok) {
@@ -2169,7 +2395,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2169
2395
  for (const ch of channels) {
2170
2396
  if (ch?.type !== "direct") continue;
2171
2397
  try {
2172
- 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`, {
2173
2399
  headers: { "Authorization": `Bearer ${TOKEN}` },
2174
2400
  });
2175
2401
  if (!mr.ok) continue;
@@ -2188,7 +2414,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2188
2414
 
2189
2415
  if (name === "list_loops") {
2190
2416
  try {
2191
- 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}` } });
2192
2418
  if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2193
2419
  const data = await r.json() as any;
2194
2420
  const loops = Array.isArray(data?.loops) ? data.loops : [];
@@ -2206,7 +2432,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2206
2432
 
2207
2433
  if (name === "my_entitlements") {
2208
2434
  try {
2209
- 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}` } });
2210
2436
  if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2211
2437
  const data = await r.json() as any;
2212
2438
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
@@ -2225,7 +2451,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2225
2451
  if (name === "list_members") {
2226
2452
  const { chat_id } = args as any;
2227
2453
  try {
2228
- 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}` } });
2229
2455
  if (r.ok) {
2230
2456
  const data = await r.json() as any;
2231
2457
  const members = data.members || [];
@@ -2242,7 +2468,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2242
2468
  if (name === "get_history") {
2243
2469
  const { chat_id, limit = 20 } = args as any;
2244
2470
  try {
2245
- 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}` } });
2246
2472
  if (r.ok) {
2247
2473
  const data = await r.json() as any;
2248
2474
  const msgs = (data.messages || []).filter((m: any) => m.content !== "__typing__");
@@ -2274,13 +2500,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2274
2500
  // Find and load the profile
2275
2501
  const targetFile = nameToPath(profile_name);
2276
2502
  if (!existsSync(targetFile)) {
2277
- 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 };
2278
2504
  }
2279
2505
 
2280
2506
  const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
2281
2507
 
2282
2508
  // Stop heartbeat first to prevent race with old connection
2283
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; }
2284
2513
 
2285
2514
  // Close old connection, disable its reconnect handler
2286
2515
  if (ws) {
@@ -2309,35 +2538,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2309
2538
  const qs = new URLSearchParams();
2310
2539
  const normalizedLevel = normalizeChannelDocLevel(level);
2311
2540
  if (level !== undefined && normalizedLevel === null) {
2312
- 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 };
2313
2542
  }
2314
2543
  if (normalizedLevel !== null) qs.set("level", String(normalizedLevel));
2315
2544
  const url = `${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs${qs.toString() ? `?${qs}` : ""}`;
2316
2545
  try {
2317
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2546
+ const r = await apiFetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2318
2547
  const text = await r.text();
2319
2548
  if (!r.ok) {
2320
- 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 };
2321
2550
  }
2322
2551
  return { content: [{ type: "text", text }] };
2323
2552
  } catch (e: any) {
2324
- 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 };
2325
2554
  }
2326
2555
  }
2327
2556
 
2328
2557
  if (name === "get_channel_doc") {
2329
2558
  const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
2330
2559
  try {
2331
- 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)}`, {
2332
2561
  headers: { "Authorization": `Bearer ${TOKEN}` },
2333
2562
  });
2334
2563
  const text = await r.text();
2335
2564
  if (!r.ok) {
2336
- 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 };
2337
2566
  }
2338
2567
  return { content: [{ type: "text", text }] };
2339
2568
  } catch (e: any) {
2340
- 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 };
2341
2570
  }
2342
2571
  }
2343
2572
 
@@ -2353,10 +2582,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2353
2582
  };
2354
2583
  const normalizedLevel = normalizeChannelDocLevel(level);
2355
2584
  if (normalizedLevel === null) {
2356
- 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 };
2357
2586
  }
2358
2587
  try {
2359
- 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)}`, {
2360
2589
  method: "PUT",
2361
2590
  headers: {
2362
2591
  "Content-Type": "application/json",
@@ -2367,27 +2596,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2367
2596
  });
2368
2597
  const text = await r.text();
2369
2598
  if (!r.ok) {
2370
- 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 };
2371
2600
  }
2372
2601
  return { content: [{ type: "text", text }] };
2373
2602
  } catch (e: any) {
2374
- 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 };
2375
2604
  }
2376
2605
  }
2377
2606
 
2378
2607
  if (name === "list_channel_doc_revisions") {
2379
2608
  const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
2380
2609
  try {
2381
- 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`, {
2382
2611
  headers: { "Authorization": `Bearer ${TOKEN}` },
2383
2612
  });
2384
2613
  const text = await r.text();
2385
2614
  if (!r.ok) {
2386
- 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 };
2387
2616
  }
2388
2617
  return { content: [{ type: "text", text }] };
2389
2618
  } catch (e: any) {
2390
- 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 };
2391
2620
  }
2392
2621
  }
2393
2622
 
@@ -2395,7 +2624,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2395
2624
  // Maps 1:1 to the server-side routes shipped in commit 5229aab
2396
2625
  // (projects/AgentChat/Server/src/okr.ts + index.ts dispatch block).
2397
2626
  if (name === "okr_list") {
2398
- 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 };
2399
2628
  const qs = new URLSearchParams();
2400
2629
  if (owner) qs.set("owner", owner);
2401
2630
  if (status) qs.set("status", status);
@@ -2403,17 +2632,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2403
2632
  if (include_archived) qs.set("include_archived", "true");
2404
2633
  if (view) qs.set("view", view);
2405
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);
2406
2637
  const url = `${REST_URL}/api/okr/objectives${qs.toString() ? "?" + qs.toString() : ""}`;
2407
2638
  try {
2408
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2639
+ const r = await apiFetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2409
2640
  if (!r.ok) {
2410
2641
  const err = await r.text();
2411
- 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 };
2412
2643
  }
2413
2644
  const data = await r.json() as any;
2414
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2645
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
2415
2646
  } catch (e: any) {
2416
- 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 };
2417
2648
  }
2418
2649
  }
2419
2650
 
@@ -2427,18 +2658,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2427
2658
  if (due) body.due = due;
2428
2659
  if (discussion_channel_id) body.discussion_channel_id = discussion_channel_id;
2429
2660
  try {
2430
- const r = await fetch(`${REST_URL}/api/okr/objectives`, {
2661
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives`, {
2431
2662
  method: "POST",
2432
2663
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2433
2664
  body: JSON.stringify(body),
2434
2665
  });
2435
2666
  const text = await r.text();
2436
2667
  if (!r.ok) {
2437
- 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 };
2438
2669
  }
2439
2670
  return { content: [{ type: "text", text: `Created: ${text}` }] };
2440
2671
  } catch (e: any) {
2441
- 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 };
2442
2673
  }
2443
2674
  }
2444
2675
 
@@ -2451,18 +2682,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2451
2682
  if (Array.isArray(depends_on) && depends_on.length > 0) body.depends_on = depends_on;
2452
2683
  if (due) body.due = due;
2453
2684
  try {
2454
- 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`, {
2455
2686
  method: "POST",
2456
2687
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2457
2688
  body: JSON.stringify(body),
2458
2689
  });
2459
2690
  const text = await r.text();
2460
2691
  if (!r.ok) {
2461
- 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 };
2462
2693
  }
2463
2694
  return { content: [{ type: "text", text: `Added: ${text}` }] };
2464
2695
  } catch (e: any) {
2465
- 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 };
2466
2697
  }
2467
2698
  }
2468
2699
 
@@ -2476,18 +2707,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2476
2707
  if (Array.isArray(depends_on)) patch.depends_on = depends_on;
2477
2708
  if (due) patch.due = due;
2478
2709
  try {
2479
- 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)}`, {
2480
2711
  method: "PATCH",
2481
2712
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2482
2713
  body: JSON.stringify(patch),
2483
2714
  });
2484
2715
  const text = await r.text();
2485
2716
  if (!r.ok) {
2486
- 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 };
2487
2718
  }
2488
2719
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2489
2720
  } catch (e: any) {
2490
- 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 };
2491
2722
  }
2492
2723
  }
2493
2724
 
@@ -2495,34 +2726,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2495
2726
  const { task_id } = args as { task_id: string };
2496
2727
  const path = name === "okr_task_blockers" ? "blockers" : "blocks";
2497
2728
  try {
2498
- 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}`, {
2499
2730
  headers: { "Authorization": `Bearer ${TOKEN}` },
2500
2731
  });
2501
2732
  const text = await r.text();
2502
2733
  if (!r.ok) {
2503
- 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 };
2504
2735
  }
2505
2736
  return { content: [{ type: "text", text }] };
2506
2737
  } catch (e: any) {
2507
- 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 };
2508
2739
  }
2509
2740
  }
2510
2741
 
2511
2742
  if (name === "okr_open_thread") {
2512
2743
  const { target_type, target_id } = args as { target_type: string; target_id: string };
2513
2744
  try {
2514
- const r = await fetch(`${REST_URL}/api/okr/threads`, {
2745
+ const r = await apiFetch(`${REST_URL}/api/okr/threads`, {
2515
2746
  method: "POST",
2516
2747
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2517
2748
  body: JSON.stringify({ target_type, target_id }),
2518
2749
  });
2519
2750
  const text = await r.text();
2520
2751
  if (!r.ok) {
2521
- 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 };
2522
2753
  }
2523
2754
  return { content: [{ type: "text", text }] };
2524
2755
  } catch (e: any) {
2525
- 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 };
2526
2757
  }
2527
2758
  }
2528
2759
 
@@ -2534,53 +2765,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2534
2765
  if (typeof current === "number") body.current = current;
2535
2766
  if (risk_level) body.risk_level = risk_level;
2536
2767
  try {
2537
- 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`, {
2538
2769
  method: "POST",
2539
2770
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2540
2771
  body: JSON.stringify(body),
2541
2772
  });
2542
2773
  const text = await r.text();
2543
2774
  if (!r.ok) {
2544
- 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 };
2545
2776
  }
2546
2777
  return { content: [{ type: "text", text: `Added: ${text}` }] };
2547
2778
  } catch (e: any) {
2548
- 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 };
2549
2780
  }
2550
2781
  }
2551
2782
 
2552
2783
  if (name === "archive_objective") {
2553
2784
  const { objective_id, completion_summary } = args as { objective_id: string; completion_summary?: string };
2554
2785
  try {
2555
- 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`, {
2556
2787
  method: "POST",
2557
2788
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2558
2789
  body: JSON.stringify(completion_summary !== undefined ? { completion_summary } : {}),
2559
2790
  });
2560
2791
  const text = await r.text();
2561
2792
  if (!r.ok) {
2562
- 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 };
2563
2794
  }
2564
2795
  return { content: [{ type: "text", text: `Archived: ${text}` }] };
2565
2796
  } catch (e: any) {
2566
- 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 };
2567
2798
  }
2568
2799
  }
2569
2800
 
2570
2801
  if (name === "unarchive_objective") {
2571
2802
  const { objective_id } = args as { objective_id: string };
2572
2803
  try {
2573
- 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`, {
2574
2805
  method: "POST",
2575
2806
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2576
2807
  });
2577
2808
  const text = await r.text();
2578
2809
  if (!r.ok) {
2579
- 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 };
2580
2811
  }
2581
2812
  return { content: [{ type: "text", text: `Unarchived: ${text}` }] };
2582
2813
  } catch (e: any) {
2583
- 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 };
2584
2815
  }
2585
2816
  }
2586
2817
 
@@ -2589,18 +2820,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2589
2820
  const body: Record<string, unknown> = { current };
2590
2821
  if (risk_level) body.risk_level = risk_level;
2591
2822
  try {
2592
- 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`, {
2593
2824
  method: "PATCH",
2594
2825
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2595
2826
  body: JSON.stringify(body),
2596
2827
  });
2597
2828
  const text = await r.text();
2598
2829
  if (!r.ok) {
2599
- 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 };
2600
2831
  }
2601
2832
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2602
2833
  } catch (e: any) {
2603
- 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 };
2604
2835
  }
2605
2836
  }
2606
2837
 
@@ -2608,18 +2839,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2608
2839
  const { task_id, text: rawText } = args as { task_id: string; text: string };
2609
2840
  const text = redactSecrets(rawText);
2610
2841
  try {
2611
- 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`, {
2612
2843
  method: "POST",
2613
2844
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2614
2845
  body: JSON.stringify({ text }),
2615
2846
  });
2616
2847
  const body = await r.text();
2617
2848
  if (!r.ok) {
2618
- 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 };
2619
2850
  }
2620
2851
  return { content: [{ type: "text", text: `Commented: ${body}` }] };
2621
2852
  } catch (e: any) {
2622
- 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 };
2623
2854
  }
2624
2855
  }
2625
2856
 
@@ -2640,22 +2871,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2640
2871
  if (linked_docs !== undefined) body.linked_docs = linked_docs;
2641
2872
  if (linked_channel_docs !== undefined) body.linked_channel_docs = linked_channel_docs;
2642
2873
  try {
2643
- 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)}`, {
2644
2875
  method: "PATCH",
2645
2876
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2646
2877
  body: JSON.stringify(body),
2647
2878
  });
2648
2879
  const text = await r.text();
2649
2880
  if (!r.ok) {
2650
- 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 };
2651
2882
  }
2652
2883
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2653
2884
  } catch (e: any) {
2654
- 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 };
2655
2886
  }
2656
2887
  }
2657
2888
 
2658
- 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
+ }
2659
2893
  });
2660
2894
 
2661
2895
  // --- WebSocket Connection ---
@@ -2665,13 +2899,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2665
2899
  const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
2666
2900
  function loadMentionTimestamps(): Map<string, string> {
2667
2901
  try {
2668
- const raw = require("fs").readFileSync(mentionTsFile, "utf-8");
2902
+ const raw = readFileSync(mentionTsFile, "utf-8");
2669
2903
  return new Map(Object.entries(JSON.parse(raw)));
2670
2904
  } catch { return new Map(); }
2671
2905
  }
2672
2906
  function saveMentionTimestamps(m: Map<string, string>) {
2673
2907
  try {
2674
- require("fs").writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2908
+ writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2675
2909
  } catch {}
2676
2910
  }
2677
2911
  const lastMentionTimestamp = loadMentionTimestamps();
@@ -2694,13 +2928,13 @@ const lastMentionTimestamp = loadMentionTimestamps();
2694
2928
  const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
2695
2929
  function loadLastSeenMessageTs(): Map<string, string> {
2696
2930
  try {
2697
- const raw = require("fs").readFileSync(lastSeenMessageTsFile, "utf-8");
2931
+ const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
2698
2932
  return new Map(Object.entries(JSON.parse(raw)));
2699
2933
  } catch { return new Map(); }
2700
2934
  }
2701
2935
  function saveLastSeenMessageTs(m: Map<string, string>) {
2702
2936
  try {
2703
- require("fs").writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2937
+ writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2704
2938
  } catch {}
2705
2939
  }
2706
2940
  const lastSeenMessageTs = loadLastSeenMessageTs();
@@ -2731,15 +2965,9 @@ function scheduleLastSeenMessageTsSave() {
2731
2965
  (lastSeenMessageTsTimer as any).unref?.();
2732
2966
  }
2733
2967
 
2734
- function normalizeTimestampForCursor(ts: string | undefined, mode: "before" | "after"): string | undefined {
2735
- if (!ts || typeof ts !== "string") return ts;
2736
- const m = ts.match(/^(.*\.)(\d+)(Z)$/);
2737
- if (!m) return ts;
2738
- const frac = m[2];
2739
- if (frac.length >= 9) return ts;
2740
- const padChar = mode === "before" ? "9" : "0";
2741
- return m[1] + frac + padChar.repeat(9 - frac.length) + m[3];
2742
- }
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.
2743
2971
 
2744
2972
  function normalizeChannelDocLevel(level: unknown): number | null {
2745
2973
  if (typeof level === "number" && Number.isInteger(level) && level >= 1 && level <= 4) {
@@ -2837,7 +3065,7 @@ function activateHiddenIdentityGame(gameId: string, channelId?: string) {
2837
3065
 
2838
3066
  async function fetchHiddenIdentityChannelId(gameId: string): Promise<string | undefined> {
2839
3067
  try {
2840
- 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)}`, {
2841
3069
  headers: { "Authorization": `Bearer ${TOKEN}` },
2842
3070
  });
2843
3071
  if (!r.ok) return undefined;
@@ -2883,31 +3111,22 @@ function clearFinishedHiddenIdentityGamesFromMessage(data: any) {
2883
3111
  // legitimately receive the same persisted message once via live WS and once
2884
3112
  // via REST backfill; timestamp guards alone would either fail to drop that
2885
3113
  // duplicate or drop older out-of-order messages that were never processed.
2886
- const deliveredMessageIds = new Set<string>();
2887
- 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();
2888
3117
  function deliverySource(data: any): string {
2889
3118
  return typeof data?.__source === "string" ? data.__source : "live";
2890
3119
  }
2891
- function messageDedupKey(data: any): string | null {
2892
- if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string") return null;
2893
- return `${data.channel_id}:${data.id}`;
2894
- }
2895
3120
  function recordOrSkipDeliveredMessage(data: any): boolean {
2896
3121
  const key = messageDedupKey(data);
2897
3122
  if (!key) return false;
2898
- if (deliveredMessageIds.has(key)) {
3123
+ const skip = messageDedup.recordOrSkip(key);
3124
+ if (skip) {
2899
3125
  process.stderr.write(
2900
3126
  `[agentchat] Duplicate message skipped source=${deliverySource(data)} chat=${String(data.channel_id).slice(0, 12)} id=${String(data.id).slice(0, 12)}\n`,
2901
3127
  );
2902
- return true;
2903
- }
2904
- deliveredMessageIds.add(key);
2905
- if (deliveredMessageIds.size > MAX_DELIVERED_MESSAGE_IDS) {
2906
- const arr = [...deliveredMessageIds];
2907
- deliveredMessageIds.clear();
2908
- for (const item of arr.slice(1000)) deliveredMessageIds.add(item);
2909
3128
  }
2910
- return false;
3129
+ return skip;
2911
3130
  }
2912
3131
  // Channels we believe we're a member of. Populated from
2913
3132
  // `channel_created` events on auth_ok. Used as the backfill target set.
@@ -2918,12 +3137,11 @@ const knownChannels = new Set<string>();
2918
3137
  let currentHandleWSMessage: ((data: any) => Promise<void>) | null = null;
2919
3138
  let wsReconnectAttempt = 0;
2920
3139
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2921
- /** Flipped true when the server sends shard_moved (planned drain).
2922
- * ws.onclose checks + clears it so the close is treated as a clean
2923
- * hop, not a failure that advances the exponential backoff. */
2924
- let isPlannedReconnect = false;
3140
+ // Tracked so shutdown / switchIdentity can cancel a pending auth_ok backfill.
3141
+ let backfillTimer: ReturnType<typeof setTimeout> | null = null;
2925
3142
 
2926
3143
  function scheduleReconnect(delayMs: number) {
3144
+ if (shuttingDown) return; // don't resurrect the socket after shutdown
2927
3145
  if (reconnectTimer) clearTimeout(reconnectTimer);
2928
3146
  reconnectTimer = setTimeout(() => {
2929
3147
  reconnectTimer = null;
@@ -2956,12 +3174,29 @@ async function backfillAllChannels(): Promise<void> {
2956
3174
  const after = lastSeenMessageTs.get(channelId);
2957
3175
  const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
2958
3176
  const url = `${REST_URL}/api/channels/${encodeURIComponent(channelId)}/messages${params}`;
2959
- const res = await fetch(url, {
3177
+ const res = await apiFetch(url, {
2960
3178
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
2961
3179
  });
2962
3180
  if (!res.ok) continue;
2963
3181
  const data = await res.json() as { messages?: any[] };
2964
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
+ }
2965
3200
  // Filter out self-messages upfront (faster than re-entering the
2966
3201
  // handler just to bail) and typing placeholders (plugin ignores
2967
3202
  // them anyway).
@@ -2996,17 +3231,29 @@ async function backfillAllChannels(): Promise<void> {
2996
3231
  }
2997
3232
 
2998
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;
2999
3244
  try {
3000
- ws = new WebSocket(WS_URL);
3245
+ socket = new WebSocket(WS_URL);
3246
+ ws = socket;
3001
3247
  } catch (e) {
3002
3248
  process.stderr.write(`[agentchat] WebSocket constructor failed: ${e}, retrying in 5s\n`);
3003
- setTimeout(connectWS, 5000);
3249
+ scheduleReconnect(5000); // route through the single tracked timer
3004
3250
  return;
3005
3251
  }
3006
3252
 
3007
3253
  ws.onopen = () => {
3254
+ if (ws !== socket) return; // superseded by a newer socket
3008
3255
  try {
3009
- ws!.send(JSON.stringify({
3256
+ socket.send(JSON.stringify({
3010
3257
  type: "auth",
3011
3258
  agent_id: AGENT_ID,
3012
3259
  token: TOKEN,
@@ -3018,6 +3265,7 @@ function connectWS() {
3018
3265
  };
3019
3266
 
3020
3267
  ws.onmessage = async (event) => {
3268
+ if (ws !== socket) return; // ignore late frames from a superseded socket
3021
3269
  let data: any;
3022
3270
  try { data = JSON.parse(String(event.data)); } catch { return; }
3023
3271
  if (data && typeof data === "object" && !data.__source) data.__source = "live";
@@ -3050,9 +3298,16 @@ function connectWS() {
3050
3298
  // per-channel REST fetch and re-injects each missed message
3051
3299
  // through handleWSMessage so @mention detection + notification
3052
3300
  // path is identical to live delivery — no divergent code paths.
3053
- setTimeout(() => { void backfillAllChannels(); }, 2000);
3301
+ if (backfillTimer) clearTimeout(backfillTimer);
3302
+ backfillTimer = setTimeout(() => {
3303
+ backfillTimer = null;
3304
+ if (!shuttingDown) void backfillAllChannels();
3305
+ }, 2000);
3054
3306
  } else if (
3055
- 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") &&
3056
3311
  // Loop ticks are server-fired with sender_id=loop.agent_id, which
3057
3312
  // equals AGENT_ID when the loop owner is THIS plugin. Without this
3058
3313
  // exception the outer "skip own messages" gate swallows every tick
@@ -3107,36 +3362,19 @@ function connectWS() {
3107
3362
  }
3108
3363
 
3109
3364
  const isDM = data.channel_id?.startsWith("dm-");
3110
- // Match both `@<agentId>` and `@<displayName>(<agentId>)` formats.
3111
- // v0.6.1 used a loose `content.includes("(" + AGENT_ID + ")")` for the
3112
- // second case which fired on ANY text containing `(<agentId>)` —
3113
- // including system messages like "User joined: name (acc_xyz)" or
3114
- // moderation logs. Boss msg:fc8b9b1a — codex agent received messages
3115
- // it wasn't @-mentioned in, ate context window. Tighten the second
3116
- // clause to require an `@<displayName>` immediately before `(<id>)`.
3117
- const idEsc = (AGENT_ID || "").replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3118
- const displayMentionRe = idEsc ? new RegExp(`@[^(\\n]+\\(${idEsc}\\)`) : null;
3119
- const isMentioned = !!(
3120
- data.content?.includes(`@${AGENT_ID}`) ||
3121
- (displayMentionRe && displayMentionRe.test(data.content || ""))
3122
- );
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 || "");
3123
3369
  const activeHi = activeHiddenIdentityForChannel(data.channel_id);
3124
3370
 
3125
3371
  if (isDM || isMentioned || activeHi) {
3126
- // DM or @mention → respond
3127
- // 立即发送 typing ACK
3128
- if (isDM || isMentioned) {
3129
- try {
3130
- if (ws && ws.readyState === WebSocket.OPEN) {
3131
- ws.send(JSON.stringify({
3132
- type: "message", id: crypto.randomUUID(),
3133
- channel_id: data.channel_id, sender_id: AGENT_ID,
3134
- sender_type: "agent", content: "__typing__",
3135
- content_type: "text", timestamp: new Date().toISOString(),
3136
- }));
3137
- }
3138
- } catch {}
3139
- }
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);
3140
3378
 
3141
3379
  // For @mention in channels, fetch context since last mention
3142
3380
  let contextPrefix = "";
@@ -3154,7 +3392,7 @@ function connectWS() {
3154
3392
  // membership for private). Without the Bearer header the
3155
3393
  // MCP agent would get 401/403 and answer the @mention
3156
3394
  // without any conversation context.
3157
- const historyRes = await fetch(historyUrl, {
3395
+ const historyRes = await apiFetch(historyUrl, {
3158
3396
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
3159
3397
  });
3160
3398
  if (historyRes.ok) {
@@ -3175,7 +3413,7 @@ function connectWS() {
3175
3413
  const clipped = raw.length > maxPerMsg
3176
3414
  ? raw.slice(0, maxPerMsg) + " …[truncated]"
3177
3415
  : raw;
3178
- const size = clipped.length;
3416
+ const size = Buffer.byteLength(clipped, "utf8"); // real UTF-8 bytes (CJK is 3B/char, not 1)
3179
3417
  if (totalBytes + size > maxBytes) break;
3180
3418
  totalBytes += size;
3181
3419
  trimmed.unshift({ ...msgs[i], content: clipped });
@@ -3202,7 +3440,7 @@ function connectWS() {
3202
3440
  contextPrefix = `[HI游戏进行中 - 你是 game ${activeHi.gameId.slice(0, 8)} 的上桌玩家;此消息无需 @mention 也被实时推送。只在轮到你行动、需要讨论或需要投票时回复,否则可以旁观。]\n`;
3203
3441
  }
3204
3442
 
3205
- 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`);
3206
3444
 
3207
3445
  // 推送给 Claude Code
3208
3446
  try {
@@ -3226,7 +3464,7 @@ function connectWS() {
3226
3464
  // Channel message without @mention → silent (just log)
3227
3465
  rateLimitedLog(
3228
3466
  "silent-channel-message",
3229
- `[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`,
3230
3468
  );
3231
3469
  }
3232
3470
  } else if (data.type === "channel_created") {
@@ -3242,25 +3480,21 @@ function connectWS() {
3242
3480
  // Task #119: track channel id for reconnect backfill target set.
3243
3481
  if (typeof data.channel_id === "string") knownChannels.add(data.channel_id);
3244
3482
  } else if (data.type === "shard_moved") {
3245
- // Server instance shutting down or channel moved — reconnect
3246
- // immediately. This is a PLANNED disconnect: the server is
3247
- // giving us heads-up to move. Mark it so the upcoming
3248
- // ws.onclose doesn't treat this as a failure that increments
3249
- // wsReconnectAttempt / stretches the exponential backoff. When
3250
- // the server does a rolling deploy (several pods closing in
3251
- // sequence), without this flag each shard_moved would push the
3252
- // next reconnect 2s, 4s, 6s ... further out even though each
3253
- // 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.
3254
3488
  process.stderr.write(`[agentchat] Shard moved, reconnecting...\n`);
3255
3489
  if (data.redirect_url) {
3256
3490
  const newUrl = data.redirect_url.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
3257
3491
  process.stderr.write(`[agentchat] Redirecting to: ${newUrl}\n`);
3258
3492
  // Note: for simplicity we reconnect to original URL and let /api/shard handle routing
3259
3493
  }
3260
- isPlannedReconnect = true;
3261
- try { ws?.close(); } catch {}
3494
+ if (ws) { ws.onclose = null; try { ws.close(); } catch {} }
3262
3495
  ws = null;
3263
3496
  sessionId = null;
3497
+ wsReconnectAttempt = 0;
3264
3498
  scheduleReconnect(500);
3265
3499
  } else if (data.type === "error") {
3266
3500
  process.stderr.write(`[agentchat] Error: ${data.message}\n`);
@@ -3268,26 +3502,21 @@ function connectWS() {
3268
3502
  }
3269
3503
 
3270
3504
  ws.onclose = (event) => {
3505
+ if (ws !== socket) return; // a superseded socket's delayed close — leave live state alone
3271
3506
  sessionId = null;
3272
3507
  heartbeat.resetReconnecting(); // allow heartbeat to reconnect again if needed
3273
- // Planned reconnect (server-initiated shard_moved): reset backoff
3274
- // and reconnect fast, don't count this against exponential delay.
3275
- if (isPlannedReconnect) {
3276
- isPlannedReconnect = false;
3277
- wsReconnectAttempt = 0;
3278
- // A concurrent scheduleReconnect(500) from the shard_moved
3279
- // handler has already been queued — don't double-schedule.
3280
- process.stderr.write(`[agentchat] Planned close (code=${(event as any)?.code ?? "?"}), reconnect in 0.5s\n`);
3281
- return;
3282
- }
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.
3283
3512
  wsReconnectAttempt++;
3284
- const jitter = Math.random() * 3000; // 0-3s random jitter to avoid thundering herd
3285
- const delay = Math.min(wsReconnectAttempt * 2, 30) * 1000 + jitter;
3513
+ const delay = computeReconnectDelay(wsReconnectAttempt);
3286
3514
  process.stderr.write(`[agentchat] Disconnected (code=${(event as any)?.code ?? "?"}), reconnecting in ${Math.round(delay/100)/10}s (attempt ${wsReconnectAttempt})...\n`);
3287
3515
  scheduleReconnect(delay);
3288
3516
  };
3289
3517
 
3290
3518
  ws.onerror = (err) => {
3519
+ if (ws !== socket) return;
3291
3520
  process.stderr.write(`[agentchat] WebSocket error: ${err}\n`);
3292
3521
  };
3293
3522
  }
@@ -3301,7 +3530,9 @@ const heartbeat = new HeartbeatMonitor({
3301
3530
  },
3302
3531
  reconnect: () => {
3303
3532
  process.stderr.write("[agentchat] Heartbeat timeout, forcing reconnect\n");
3304
- 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 {} }
3305
3536
  ws = null;
3306
3537
  sessionId = null;
3307
3538
  wsReconnectAttempt = 0; // reset backoff for heartbeat-triggered reconnect
@@ -3317,10 +3548,15 @@ function shutdownFromStdio(reason: string) {
3317
3548
  safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3318
3549
  try { flushLastSeenMessageTs(); } catch {}
3319
3550
  try { heartbeat.stop(); } catch {}
3551
+ try { stopAllTypingHeartbeats(); } catch {}
3320
3552
  if (reconnectTimer) {
3321
3553
  clearTimeout(reconnectTimer);
3322
3554
  reconnectTimer = null;
3323
3555
  }
3556
+ if (backfillTimer) {
3557
+ clearTimeout(backfillTimer);
3558
+ backfillTimer = null;
3559
+ }
3324
3560
  try { ws?.close(); } catch {}
3325
3561
  ws = null;
3326
3562
  sessionId = null;