agentschat-mcp 0.23.1 → 0.25.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;
@@ -416,6 +499,7 @@ const TOOL_GROUPS: ToolGroupMeta[] = [
416
499
  "okr_set_links",
417
500
  "archive_objective",
418
501
  "unarchive_objective",
502
+ "okr_reparent_objective",
419
503
  ],
420
504
  },
421
505
  {
@@ -518,8 +602,7 @@ For non-trivial AgentsChat work, start from Workspace Graph/OKR state, preserve
518
602
 
519
603
  // --- Tools ---
520
604
 
521
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
522
- tools: filterVisibleTools([
605
+ const ALL_TOOL_DEFS = [
523
606
  {
524
607
  name: "reply",
525
608
  description: "Reply to an AgentsChat message. Pass the chat_id (channel_id) from the channel tag.",
@@ -1019,6 +1102,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1019
1102
  description: "Per-caller perspective on the tree. mine-active = my active tasks. blocking-me = tasks I'm waiting on. blocked-by-me = tasks waiting on me. related = anchor task's neighbourhood (requires task_id). Empty objectives are pruned.",
1020
1103
  },
1021
1104
  task_id: { type: "string", description: "Anchor task id; only meaningful with view=related" },
1105
+ 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." },
1106
+ objective_id: { type: "string", description: "Return the full subtree (KRs + tasks + comments) for a single objective." },
1022
1107
  },
1023
1108
  },
1024
1109
  },
@@ -1144,6 +1229,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1144
1229
  required: ["objective_id"],
1145
1230
  },
1146
1231
  },
1232
+ {
1233
+ name: "okr_reparent_objective",
1234
+ description: "Re-parent one of YOUR objectives under another objective (build the company OKR tree), or detach it to a top-level root with parent_id=null. Owner-only; the server rejects cycles and depth >3. Use this instead of a hand-rolled curl — the plugin handles auth for you.",
1235
+ inputSchema: {
1236
+ type: "object" as const,
1237
+ properties: {
1238
+ objective_id: { type: "string", description: "Your objective's id to move" },
1239
+ parent_id: { type: ["string", "null"], description: "New parent objective id to attach under, or null to detach to a top-level root. Required to be present (pass null explicitly to detach)." },
1240
+ },
1241
+ required: ["objective_id"],
1242
+ },
1243
+ },
1147
1244
  {
1148
1245
  name: "okr_set_kr_progress",
1149
1246
  description: "Update a KR's current value (progress ping) and optionally risk_level. Allowed for the Objective owner, an admin, or any task assignee whose task contributes_to this KR (self-report path). Unthrottled — progress updates are expected to be frequent during a sprint.",
@@ -1264,15 +1361,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1264
1361
  required: ["chat_id", "doc_id"],
1265
1362
  },
1266
1363
  },
1267
- ]),
1268
- }));
1269
-
1270
- /** Redact sensitive tokens from outgoing message content */
1271
- function redactSecrets(text: string): string {
1272
- return text
1273
- .replace(/ac_[A-Za-z0-9]{16,}/g, "ac_***REDACTED***")
1274
- .replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "***JWT_REDACTED***");
1275
- }
1364
+ ];
1365
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: filterVisibleTools(ALL_TOOL_DEFS) }));
1366
+ // Runtime input-validation registry: tool name → its declared inputSchema.
1367
+ // Consulted at dispatch to reject contract-violating args with a clear isError (B1b).
1368
+ const TOOL_INPUT_SCHEMAS: Map<string, any> = new Map(
1369
+ (ALL_TOOL_DEFS as any[]).map((t) => [t.name, t.inputSchema]),
1370
+ );
1371
+
1372
+ // redactSecrets now lives in ./redact.ts (imported above) so it can be
1373
+ // unit-tested without loading this side-effecting entrypoint.
1276
1374
 
1277
1375
  /**
1278
1376
  * Per-channel member cache for the bare-@-to-paren resolver below.
@@ -1287,7 +1385,7 @@ async function fetchChannelMembers(chatId: string): Promise<{ agent_id: string;
1287
1385
  const hit = memberCache.get(chatId);
1288
1386
  if (hit && now - hit.at < MEMBER_CACHE_TTL_MS) return hit.members;
1289
1387
  try {
1290
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1388
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chatId)}/members`, {
1291
1389
  headers: { "Authorization": `Bearer ${TOKEN}` },
1292
1390
  });
1293
1391
  if (!r.ok) return hit?.members || [];
@@ -1329,9 +1427,69 @@ async function resolveBareMentions(chatId: string, text: string): Promise<string
1329
1427
  });
1330
1428
  }
1331
1429
 
1430
+ // ── Tool handler registry (strangler-fig migration off the if-chain) ──────────
1431
+ // Handlers registered here are dispatched O(1) in the CallTool handler below,
1432
+ // after the arg-validation + extended-compat + visibility preamble; any tool not
1433
+ // yet migrated falls through to the legacy if-chain. Handlers close over module
1434
+ // state (ws, TOKEN, apiFetch, AGENT_ID, …) exactly as the inline blocks did, and
1435
+ // are populated at module load. Migrating in verified slices, not a big-bang.
1436
+ type ToolHandler = (args: any, name: string, request: any) => Promise<{ content: any[]; isError?: boolean }>;
1437
+ const HANDLERS = new Map<string, ToolHandler>();
1438
+
1439
+ HANDLERS.set("send_typing", async (args) => {
1440
+ const { chat_id } = args as { chat_id: string };
1441
+ if (ws && ws.readyState === WebSocket.OPEN) {
1442
+ ws.send(JSON.stringify({
1443
+ type: "typing",
1444
+ channel_id: chat_id,
1445
+ sender_id: AGENT_ID,
1446
+ cross_pod: true, // agent-originated → hub fans out cross-pod (humans on other pods see it)
1447
+ }));
1448
+ }
1449
+ return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1450
+ });
1451
+
1452
+ HANDLERS.set("okr_reparent_objective", async (args) => {
1453
+ const { objective_id, parent_id } = (args || {}) as { objective_id?: string; parent_id?: string | null };
1454
+ if (!objective_id) {
1455
+ return { content: [{ type: "text", text: "okr_reparent_objective needs objective_id." }], isError: true };
1456
+ }
1457
+ // parent_id must be PRESENT (a string to attach under, or explicit null to
1458
+ // detach to a root). Absent ≠ null — we don't silently detach on omission.
1459
+ if (!(args && typeof args === "object" && "parent_id" in args)) {
1460
+ return { content: [{ type: "text", text: "okr_reparent_objective needs parent_id (an objective id to attach under, or null to detach to a top-level root)." }], isError: true };
1461
+ }
1462
+ try {
1463
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/parent`, {
1464
+ method: "PATCH",
1465
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1466
+ body: JSON.stringify({ parent_id: parent_id ?? null }),
1467
+ });
1468
+ const text = await r.text();
1469
+ if (!r.ok) {
1470
+ return { content: [{ type: "text", text: `okr_reparent_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
1471
+ }
1472
+ return { content: [{ type: "text", text: `Reparented: ${text}` }] };
1473
+ } catch (e: any) {
1474
+ return { content: [{ type: "text", text: `okr_reparent_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1475
+ }
1476
+ });
1477
+
1332
1478
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1333
1479
  let { name, arguments: args } = request.params;
1334
1480
  let viaExtendedCompat = false;
1481
+ // Central failure boundary: any handler that throws (network, JSON, ws.send,
1482
+ // unexpected shape) becomes an isError result instead of a silent/opaque
1483
+ // SDK error, so the calling agent always learns the call failed. Explicit
1484
+ // error returns below carry isError:true individually.
1485
+ try {
1486
+ // Validate args against the tool's declared inputSchema (required present +
1487
+ // declared types) before any side effect. Permissive: unknown tools and
1488
+ // undeclared fields pass through; only contract violations are rejected.
1489
+ if (TOOL_INPUT_SCHEMAS.has(name)) {
1490
+ const argErr = validateToolArgs(TOOL_INPUT_SCHEMAS.get(name), args);
1491
+ if (argErr) return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
1492
+ }
1335
1493
 
1336
1494
  if (name === "list_skills") {
1337
1495
  const { chat_id } = (args || {}) as { chat_id?: string };
@@ -1345,7 +1503,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1345
1503
  };
1346
1504
  if (chat_id) {
1347
1505
  try {
1348
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs`, {
1506
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs`, {
1349
1507
  headers: { "Authorization": `Bearer ${TOKEN}` },
1350
1508
  });
1351
1509
  if (r.ok) out.channel_skills = extractChannelDocsPayload(JSON.parse(await r.text())).filter(isSkillDoc).map(compactSkillDoc);
@@ -1356,10 +1514,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1356
1514
  }
1357
1515
  // Personal skills (per-owner, follow you across agents). sync_skill(name=…) / load via GET /api/skills/:name.
1358
1516
  try {
1359
- const pr = await fetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1517
+ const pr = await apiFetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1360
1518
  if (pr.ok) out.personal_skills = (JSON.parse(await pr.text()).skills) || [];
1361
1519
  } catch { /* best-effort */ }
1362
- return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1520
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
1363
1521
  }
1364
1522
 
1365
1523
  if (name === "load_skill") {
@@ -1367,12 +1525,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1367
1525
  // CHANNEL skill: chat_id + doc_id (full frontmatter/metadata handling).
1368
1526
  if (chat_id && doc_id) {
1369
1527
  try {
1370
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1528
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
1371
1529
  headers: { "Authorization": `Bearer ${TOKEN}` },
1372
1530
  });
1373
1531
  const text = await r.text();
1374
1532
  if (!r.ok) {
1375
- return { content: [{ type: "text", text: `load_skill (channel) failed (${r.status}): ${text.slice(0, 200)}` }] };
1533
+ return { content: [{ type: "text", text: `load_skill (channel) failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
1376
1534
  }
1377
1535
  const doc = JSON.parse(text);
1378
1536
  const body = doc?.body_markdown ?? doc?.bodyMarkdown ?? "";
@@ -1401,14 +1559,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1401
1559
  }],
1402
1560
  };
1403
1561
  } catch (e: any) {
1404
- return { content: [{ type: "text", text: `load_skill (channel) network/parse error: ${String(e?.message || e).slice(0, 120)}` }] };
1562
+ return { content: [{ type: "text", text: `load_skill (channel) network/parse error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1405
1563
  }
1406
1564
  }
1407
1565
  // GLOBAL skill: skill_id (default workspace-driven-eng).
1408
1566
  const id = skill_id || DEFAULT_GLOBAL_SKILL_ID;
1409
1567
  const skill = GLOBAL_SKILLS[id];
1410
1568
  if (!skill) {
1411
- return { content: [{ type: "text", text: `Unknown global skill: ${id}` }] };
1569
+ return { content: [{ type: "text", text: `Unknown global skill: ${id}` }], isError: true };
1412
1570
  }
1413
1571
  return { content: [{ type: "text", text: `${skill.body}\n\nLoaded as global skill "${id}".` }] };
1414
1572
  }
@@ -1416,20 +1574,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1416
1574
  if (name === "save_memory") {
1417
1575
  const a = (args || {}) as { name?: string; body?: string; description?: string };
1418
1576
  if (!a.name || !a.body) {
1419
- return { content: [{ type: "text", text: "save_memory needs name (slug) + body (markdown). Optional description (one-line index hook)." }] };
1577
+ return { content: [{ type: "text", text: "save_memory needs name (slug) + body (markdown). Optional description (one-line index hook)." }], isError: true };
1420
1578
  }
1421
1579
  try {
1422
- const r = await fetch(`${REST_URL}/api/memory/${encodeURIComponent(a.name)}`, {
1580
+ const r = await apiFetch(`${REST_URL}/api/memory/${encodeURIComponent(a.name)}`, {
1423
1581
  method: "PUT",
1424
1582
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1425
1583
  body: JSON.stringify({ body_markdown: a.body, description: a.description }),
1426
1584
  });
1427
1585
  const text = await r.text();
1428
- if (!r.ok) return { content: [{ type: "text", text: `save_memory failed (${r.status}): ${text.slice(0, 240)}` }] };
1586
+ if (!r.ok) return { content: [{ type: "text", text: `save_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1429
1587
  const resp = JSON.parse(text);
1430
1588
  return { content: [{ type: "text", text: `Saved memory "${resp.name}" (v${resp.version}, ${resp.bytes}B) under your agent_id. Restore later: load_memory (index) → load_memory("${resp.name}").` }] };
1431
1589
  } catch (e: any) {
1432
- return { content: [{ type: "text", text: `save_memory network error: ${String(e?.message || e).slice(0, 120)}` }] };
1590
+ return { content: [{ type: "text", text: `save_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1433
1591
  }
1434
1592
  }
1435
1593
 
@@ -1437,9 +1595,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1437
1595
  const a = (args || {}) as { name?: string };
1438
1596
  try {
1439
1597
  const path = a.name ? `/api/memory/${encodeURIComponent(a.name)}` : `/api/memory`;
1440
- const r = await fetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1598
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1441
1599
  const text = await r.text();
1442
- if (!r.ok) return { content: [{ type: "text", text: `load_memory failed (${r.status}): ${text.slice(0, 240)}` }] };
1600
+ if (!r.ok) return { content: [{ type: "text", text: `load_memory failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1443
1601
  const resp = JSON.parse(text);
1444
1602
  if (a.name) {
1445
1603
  return { content: [{ type: "text", text: `# memory: ${resp.name} (v${resp.version})\n\n${resp.body_markdown || ""}` }] };
@@ -1449,14 +1607,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1449
1607
  const idx = items.map((m: any) => `- ${m.name}${m.description ? ` — ${m.description}` : ""}`).join("\n");
1450
1608
  return { content: [{ type: "text", text: `Your memory index (${items.length} docs). Load one in full with load_memory("<name>"):\n\n${idx}` }] };
1451
1609
  } catch (e: any) {
1452
- return { content: [{ type: "text", text: `load_memory network error: ${String(e?.message || e).slice(0, 120)}` }] };
1610
+ return { content: [{ type: "text", text: `load_memory network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1453
1611
  }
1454
1612
  }
1455
1613
 
1456
1614
  if (name === "save_skill") {
1457
1615
  const a = (args || {}) as { chat_id?: string; name?: string; description?: string; body?: string; doc_id?: string; level?: number };
1458
1616
  if (!a.name || !a.description) {
1459
- return { content: [{ type: "text", text: "save_skill needs name + description (body is the skill markdown). Pass chat_id for a CHANNEL skill, or OMIT chat_id for a PERSONAL skill that follows you across all your agents." }] };
1617
+ return { content: [{ type: "text", text: "save_skill needs name + description (body is the skill markdown). Pass chat_id for a CHANNEL skill, or OMIT chat_id for a PERSONAL skill that follows you across all your agents." }], isError: true };
1460
1618
  }
1461
1619
  // Skill markdown = YAML frontmatter (name, description) + body — the exact
1462
1620
  // shape parseSkillFrontmatter + the skill validators expect.
@@ -1466,17 +1624,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1466
1624
  if (!a.chat_id) {
1467
1625
  const pslug = String(a.doc_id || a.name).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "").slice(0, 64) || "skill";
1468
1626
  try {
1469
- const r = await fetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
1627
+ const r = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(pslug)}`, {
1470
1628
  method: "PUT",
1471
1629
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
1472
1630
  body: JSON.stringify({ body_markdown: md }),
1473
1631
  });
1474
1632
  const text = await r.text();
1475
- if (!r.ok) return { content: [{ type: "text", text: `save_skill (personal) failed (${r.status}): ${text.slice(0, 240)}` }] };
1633
+ if (!r.ok) return { content: [{ type: "text", text: `save_skill (personal) failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1476
1634
  const resp = JSON.parse(text);
1477
1635
  return { content: [{ type: "text", text: `Saved PERSONAL skill "${a.name}" as "${pslug}" (v${resp.version}) — shared across all your agents. Pull/refresh: sync_skill(name="${pslug}"); list: list_skills.` }] };
1478
1636
  } catch (e: any) {
1479
- return { content: [{ type: "text", text: `save_skill (personal) network error: ${String(e?.message || e).slice(0, 120)}` }] };
1637
+ return { content: [{ type: "text", text: `save_skill (personal) network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1480
1638
  }
1481
1639
  }
1482
1640
  // CHANNEL skill (chat_id given). Stable doc id: caller-supplied, else slug of name.
@@ -1487,24 +1645,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1487
1645
  // If-Match: fetch current version (0 = create). The doc store uses
1488
1646
  // optimistic concurrency; "0" creates, current version updates.
1489
1647
  let ifMatch = "0";
1490
- const cur = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1648
+ const cur = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1491
1649
  if (cur.ok) {
1492
1650
  const curDoc = await cur.json().catch(() => null) as any;
1493
1651
  if (curDoc && curDoc.version != null) ifMatch = String(curDoc.version);
1494
1652
  }
1495
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, {
1653
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(docId)}`, {
1496
1654
  method: "PUT",
1497
1655
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json", "If-Match": ifMatch },
1498
1656
  body: JSON.stringify({ kind: "channel_skill", level, title: a.name, body_markdown: md }),
1499
1657
  });
1500
1658
  const text = await r.text();
1501
1659
  if (!r.ok) {
1502
- return { content: [{ type: "text", text: `save_skill failed (${r.status}): ${text.slice(0, 240)}` }] };
1660
+ return { content: [{ type: "text", text: `save_skill failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1503
1661
  }
1504
1662
  const verb = ifMatch === "0" ? "Saved" : "Updated";
1505
1663
  return { content: [{ type: "text", text: `${verb} skill "${a.name}" → ${a.chat_id}/${docId} (L${level}). Others load it with: load_skill(chat_id="${a.chat_id}", doc_id="${docId}") — discoverable via list_skills(chat_id="${a.chat_id}").` }] };
1506
1664
  } catch (e: any) {
1507
- return { content: [{ type: "text", text: `save_skill network error: ${String(e?.message || e).slice(0, 120)}` }] };
1665
+ return { content: [{ type: "text", text: `save_skill network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1508
1666
  }
1509
1667
  }
1510
1668
 
@@ -1519,29 +1677,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1519
1677
  const pBase = `${cacheDir}/personal__${safe(a.name)}`;
1520
1678
  const pMd = `${pBase}.md`; const pMeta = `${pBase}.json`;
1521
1679
  try {
1522
- const listR = await fetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1523
- if (!listR.ok) return { content: [{ type: "text", text: `sync_skill (personal): list failed (${listR.status})` }] };
1680
+ const listR = await apiFetch(`${REST_URL}/api/skills`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1681
+ if (!listR.ok) return { content: [{ type: "text", text: `sync_skill (personal): list failed (${listR.status})` }], isError: true };
1524
1682
  const meta = (JSON.parse(await listR.text()).skills || []).find((s: any) => s.name === a.name);
1525
- if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }] };
1683
+ if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }], isError: true };
1526
1684
  const currentVersion = Number(meta.version ?? 0);
1527
1685
  let cachedVersion: number | null = null;
1528
1686
  try { cachedVersion = Number(JSON.parse(await Bun.file(pMeta).text()).version); } catch {}
1529
1687
  if (cachedVersion !== null && cachedVersion === currentVersion) {
1530
1688
  return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} — no download. Read that file to run it.` }] };
1531
1689
  }
1532
- const bodyR = await fetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1533
- if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }] };
1690
+ const bodyR = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1691
+ if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
1534
1692
  const doc = JSON.parse(await bodyR.text());
1535
1693
  await Bun.write(pMd, String(doc?.body_markdown ?? ""));
1536
1694
  await Bun.write(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
1537
1695
  return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} → ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
1538
1696
  } catch (e: any) {
1539
- return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }] };
1697
+ return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
1540
1698
  }
1541
1699
  }
1542
1700
  // CHANNEL skill: chat_id + doc_id.
1543
1701
  if (!a.chat_id || !a.doc_id) {
1544
- return { content: [{ type: "text", text: "sync_skill needs (chat_id + doc_id) for a CHANNEL skill, or (name) for a PERSONAL skill." }] };
1702
+ return { content: [{ type: "text", text: "sync_skill needs (chat_id + doc_id) for a CHANNEL skill, or (name) for a PERSONAL skill." }], isError: true };
1545
1703
  }
1546
1704
  const base = `${cacheDir}/${safe(a.chat_id)}__${safe(a.doc_id)}`;
1547
1705
  const mdPath = `${base}.md`;
@@ -1549,11 +1707,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1549
1707
  try {
1550
1708
  // 1. Cheap version check: docs-list returns each doc's version WITHOUT the
1551
1709
  // body, so "is my local copy current?" costs one light call.
1552
- const listR = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1553
- if (!listR.ok) return { content: [{ type: "text", text: `sync_skill: docs-list failed (${listR.status})` }] };
1710
+ const listR = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1711
+ if (!listR.ok) return { content: [{ type: "text", text: `sync_skill: docs-list failed (${listR.status})` }], isError: true };
1554
1712
  const docs = extractChannelDocsPayload(JSON.parse(await listR.text()));
1555
1713
  const meta = docs.find((d: any) => (d?.id ?? d?.doc_id) === a.doc_id);
1556
- if (!meta) return { content: [{ type: "text", text: `sync_skill: skill "${a.doc_id}" not found in channel ${a.chat_id}` }] };
1714
+ if (!meta) return { content: [{ type: "text", text: `sync_skill: skill "${a.doc_id}" not found in channel ${a.chat_id}` }], isError: true };
1557
1715
  const currentVersion = Number(meta.version ?? 0);
1558
1716
  // 2. Local cache check — skip the body fetch if we already have this version.
1559
1717
  let cachedVersion: number | null = null;
@@ -1562,8 +1720,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1562
1720
  return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} — no download. Read that file to run it.` }] };
1563
1721
  }
1564
1722
  // 3. Missing/stale → fetch the body (the only expensive call, on the cold path).
1565
- const docR = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(a.doc_id)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1566
- if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }] };
1723
+ const docR = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(a.chat_id)}/docs/${encodeURIComponent(a.doc_id)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1724
+ if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }], isError: true };
1567
1725
  const doc = JSON.parse(await docR.text());
1568
1726
  const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
1569
1727
  // 4. Write the local mirror + version sidecar.
@@ -1572,7 +1730,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1572
1730
  const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
1573
1731
  return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} → ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
1574
1732
  } catch (e: any) {
1575
- return { content: [{ type: "text", text: `sync_skill error: ${String(e?.message || e).slice(0, 140)}` }] };
1733
+ return { content: [{ type: "text", text: `sync_skill error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
1576
1734
  }
1577
1735
  }
1578
1736
 
@@ -1598,7 +1756,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1598
1756
  const { group_name } = (args || {}) as { group_name: ToolGroupName };
1599
1757
  const group = TOOL_GROUPS.find((item) => item.name === group_name);
1600
1758
  if (!group) {
1601
- return { content: [{ type: "text", text: `Unknown tool group: ${String(group_name)}` }] };
1759
+ return { content: [{ type: "text", text: `Unknown tool group: ${String(group_name)}` }], isError: true };
1602
1760
  }
1603
1761
  const wasLoaded = loadedToolGroups.has(group.name);
1604
1762
  if (!wasLoaded) {
@@ -1623,7 +1781,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1623
1781
  const { tool_name, arguments: forwardedArgs } = (args || {}) as { tool_name?: string; arguments?: Record<string, unknown> };
1624
1782
  const groupName = tool_name ? TOOL_NAME_TO_GROUP.get(tool_name) : undefined;
1625
1783
  if (!tool_name || !groupName) {
1626
- return { content: [{ type: "text", text: `invoke_extended_tool only supports known extended tools.` }] };
1784
+ return { content: [{ type: "text", text: `invoke_extended_tool only supports known extended tools.` }], isError: true };
1627
1785
  }
1628
1786
  name = tool_name;
1629
1787
  args = forwardedArgs || {};
@@ -1643,8 +1801,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1643
1801
  }
1644
1802
  }
1645
1803
 
1804
+ // Registry dispatch: a migrated handler is looked up O(1) here; any tool not
1805
+ // yet migrated falls through to the legacy if-chain below (strangler-fig).
1806
+ {
1807
+ const registered = HANDLERS.get(name);
1808
+ if (registered) return await registered(args, name, request);
1809
+ }
1810
+
1646
1811
  if (name === "reply") {
1647
1812
  const { chat_id, text: rawText } = args as { chat_id: string; text: string };
1813
+ stopTypingHeartbeat(chat_id); // we're answering this channel → stop the "thinking" pulse
1648
1814
  // Order: resolve bare @<display_name> to paren form FIRST (so the
1649
1815
  // receiving MCP plugin's regex triggers), then redact secrets so
1650
1816
  // an accidental `ac_xxx` in the text gets masked regardless of
@@ -1652,7 +1818,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1652
1818
  const text = redactSecrets(await resolveBareMentions(chat_id, rawText));
1653
1819
  // Use REST API for reliable delivery (WebSocket may be half-open after deploy)
1654
1820
  try {
1655
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1821
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1656
1822
  method: "POST",
1657
1823
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1658
1824
  body: JSON.stringify({
@@ -1682,18 +1848,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1682
1848
  }
1683
1849
  }
1684
1850
 
1685
- if (name === "send_typing") {
1686
- const { chat_id } = args as { chat_id: string };
1687
- if (ws && ws.readyState === WebSocket.OPEN) {
1688
- ws.send(JSON.stringify({
1689
- type: "typing",
1690
- channel_id: chat_id,
1691
- sender_id: AGENT_ID,
1692
- }));
1693
- }
1694
- return { content: [{ type: "text", text: "Typing indicator dispatched" }] };
1695
- }
1696
-
1697
1851
  if (name === "react") {
1698
1852
  const { chat_id, message_id, emoji, action } = args as any;
1699
1853
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -1734,8 +1888,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1734
1888
  }
1735
1889
 
1736
1890
  if (name === "edit_message") {
1737
- const { chat_id, message_id, new_content } = args as any;
1891
+ const { chat_id, message_id, new_content: rawNewContent } = args as any;
1892
+ if (typeof rawNewContent !== "string") {
1893
+ return { content: [{ type: "text", text: "Error: new_content (string) required" }] };
1894
+ }
1738
1895
  if (ws && ws.readyState === WebSocket.OPEN) {
1896
+ // Mirror reply(): resolve bare @mentions then redact secrets so an
1897
+ // accidental ac_/JWT in edited content is masked like every other
1898
+ // outbound content path (this was the one hole that bypassed it).
1899
+ const new_content = redactSecrets(await resolveBareMentions(chat_id, rawNewContent));
1739
1900
  ws.send(JSON.stringify({
1740
1901
  type: "edit_message", message_id, channel_id: chat_id,
1741
1902
  sender_id: AGENT_ID, new_content, timestamp: new Date().toISOString(),
@@ -1760,7 +1921,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1760
1921
  const { status_text, status_emoji } = args as any;
1761
1922
  if (ws && ws.readyState === WebSocket.OPEN) {
1762
1923
  ws.send(JSON.stringify({
1763
- type: "set_status", sender_id: AGENT_ID, status_text, status_emoji,
1924
+ type: "set_status", sender_id: AGENT_ID,
1925
+ status_text: typeof status_text === "string" ? redactSecrets(status_text) : status_text,
1926
+ status_emoji,
1764
1927
  }));
1765
1928
  return { content: [{ type: "text", text: `Status update dispatched: ${status_emoji || ''} ${status_text}` }] };
1766
1929
  }
@@ -1784,7 +1947,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1784
1947
  free_text?: string;
1785
1948
  };
1786
1949
  try {
1787
- const r = await fetch(`${REST_URL}/api/moderation/report`, {
1950
+ const r = await apiFetch(`${REST_URL}/api/moderation/report`, {
1788
1951
  method: "POST",
1789
1952
  headers: {
1790
1953
  "Content-Type": "application/json",
@@ -1799,11 +1962,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1799
1962
  });
1800
1963
  const text = await r.text();
1801
1964
  if (!r.ok) {
1802
- return { content: [{ type: "text", text: `report_message failed (${r.status}): ${text.slice(0, 240)}` }] };
1965
+ return { content: [{ type: "text", text: `report_message failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1803
1966
  }
1804
1967
  return { content: [{ type: "text", text }] };
1805
1968
  } catch (e: any) {
1806
- return { content: [{ type: "text", text: `report_message network error: ${String(e?.message || e).slice(0, 120)}` }] };
1969
+ return { content: [{ type: "text", text: `report_message network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1807
1970
  }
1808
1971
  }
1809
1972
 
@@ -1812,16 +1975,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1812
1975
  const qs = new URLSearchParams();
1813
1976
  if (agent_id) qs.set("agent_id", agent_id);
1814
1977
  try {
1815
- const r = await fetch(`${REST_URL}/api/me/moderation_history${qs.toString() ? `?${qs.toString()}` : ""}`, {
1978
+ const r = await apiFetch(`${REST_URL}/api/me/moderation_history${qs.toString() ? `?${qs.toString()}` : ""}`, {
1816
1979
  headers: { "Authorization": `Bearer ${TOKEN}` },
1817
1980
  });
1818
1981
  const text = await r.text();
1819
1982
  if (!r.ok) {
1820
- return { content: [{ type: "text", text: `list_my_moderation_history failed (${r.status}): ${text.slice(0, 240)}` }] };
1983
+ return { content: [{ type: "text", text: `list_my_moderation_history failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1821
1984
  }
1822
1985
  return { content: [{ type: "text", text }] };
1823
1986
  } catch (e: any) {
1824
- return { content: [{ type: "text", text: `list_my_moderation_history network error: ${String(e?.message || e).slice(0, 120)}` }] };
1987
+ return { content: [{ type: "text", text: `list_my_moderation_history network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1825
1988
  }
1826
1989
  }
1827
1990
 
@@ -1829,21 +1992,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1829
1992
  const { limit = 20 } = args as { limit?: number };
1830
1993
  const capped = Math.max(1, Math.min(Number(limit) || 20, 100));
1831
1994
  try {
1832
- const r = await fetch(`${REST_URL}/api/me/reports_submitted?limit=${capped}`, {
1995
+ const r = await apiFetch(`${REST_URL}/api/me/reports_submitted?limit=${capped}`, {
1833
1996
  headers: { "Authorization": `Bearer ${TOKEN}` },
1834
1997
  });
1835
1998
  const text = await r.text();
1836
1999
  if (!r.ok) {
1837
- return { content: [{ type: "text", text: `list_reports_i_submitted failed (${r.status}): ${text.slice(0, 240)}` }] };
2000
+ return { content: [{ type: "text", text: `list_reports_i_submitted failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
1838
2001
  }
1839
2002
  return { content: [{ type: "text", text }] };
1840
2003
  } catch (e: any) {
1841
- return { content: [{ type: "text", text: `list_reports_i_submitted network error: ${String(e?.message || e).slice(0, 120)}` }] };
2004
+ return { content: [{ type: "text", text: `list_reports_i_submitted network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1842
2005
  }
1843
2006
  }
1844
2007
 
1845
2008
  if (name === "set_topic") {
1846
2009
  const { chat_id, topic } = args as any;
2010
+ if (typeof chat_id !== "string" || typeof topic !== "string") {
2011
+ return { content: [{ type: "text", text: "Error: chat_id and topic (strings) required" }] };
2012
+ }
1847
2013
  if (ws && ws.readyState === WebSocket.OPEN) {
1848
2014
  ws.send(JSON.stringify({ type: "set_topic", channel_id: chat_id, sender_id: AGENT_ID, topic }));
1849
2015
  return { content: [{ type: "text", text: `Topic update dispatched; server may reject (admin only): ${topic.slice(0,50)}` }] };
@@ -1853,6 +2019,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1853
2019
 
1854
2020
  if (name === "forward") {
1855
2021
  const { source_channel_id, target_channel_id, message_id } = args as any;
2022
+ if (typeof target_channel_id !== "string" || typeof message_id !== "string") {
2023
+ return { content: [{ type: "text", text: "Error: target_channel_id and message_id (strings) required" }] };
2024
+ }
1856
2025
  if (ws && ws.readyState === WebSocket.OPEN) {
1857
2026
  ws.send(JSON.stringify({
1858
2027
  type: "forward", id: crypto.randomUUID(),
@@ -1866,10 +2035,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1866
2035
 
1867
2036
  if (name === "search") {
1868
2037
  const { query, channel_id } = args as any;
2038
+ if (typeof query !== "string" || query.length === 0) {
2039
+ return { content: [{ type: "text", text: "Error: query (non-empty string) required" }] };
2040
+ }
1869
2041
  try {
1870
2042
  const params = new URLSearchParams({ q: query, limit: "20" });
1871
2043
  if (channel_id) params.set("channel_id", channel_id);
1872
- const r = await fetch(`${REST_URL}/api/search?${params}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2044
+ const r = await apiFetch(`${REST_URL}/api/search?${params}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2045
+ if (!r.ok) {
2046
+ return { content: [{ type: "text", text: `Search failed (${r.status})` }], isError: true };
2047
+ }
1873
2048
  const data = await r.json() as any;
1874
2049
  if (data.messages?.length > 0) {
1875
2050
  const results = data.messages.map((m: any) =>
@@ -1885,6 +2060,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1885
2060
 
1886
2061
  if (name === "vote") {
1887
2062
  const { proposal_id, decision, reason } = args as any;
2063
+ if (typeof proposal_id !== "string" || typeof decision !== "string") {
2064
+ return { content: [{ type: "text", text: "Error: proposal_id and decision (strings) required" }] };
2065
+ }
1888
2066
  if (ws && ws.readyState === WebSocket.OPEN) {
1889
2067
  ws.send(JSON.stringify({
1890
2068
  type: "vote", proposal_id, voter_id: AGENT_ID,
@@ -1917,7 +2095,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1917
2095
  async function channelBrief(chatId: string): Promise<string> {
1918
2096
  const get = async (path: string) => {
1919
2097
  try {
1920
- const r = await fetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2098
+ const r = await apiFetch(`${REST_URL}${path}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1921
2099
  return r.ok ? await r.json() as any : null;
1922
2100
  } catch { return null; }
1923
2101
  };
@@ -1929,8 +2107,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1929
2107
  const memberIds: string[] = (membersData?.members || []).map((m: any) => m?.agent_id).filter(Boolean);
1930
2108
  let online: string[] = [];
1931
2109
  if (memberIds.length > 0) {
1932
- const pres = await get(`/api/presence?ids=${encodeURIComponent(memberIds.slice(0, 50).join(","))}`);
1933
- online = Object.entries(pres?.presence || {}).filter(([, v]) => v === "online").map(([k]) => k);
2110
+ // Batch the presence query in chunks of 50 so channels with >50 members
2111
+ // report accurately (was: memberIds.slice(0,50) — silently wrong past 50).
2112
+ const onlineSet = new Set<string>();
2113
+ for (let i = 0; i < memberIds.length; i += 50) {
2114
+ const batch = memberIds.slice(i, i + 50);
2115
+ const pres = await get(`/api/presence?ids=${encodeURIComponent(batch.join(","))}`);
2116
+ for (const [k, v] of Object.entries(pres?.presence || {})) {
2117
+ if (v === "online") onlineSet.add(k);
2118
+ }
2119
+ }
2120
+ online = [...onlineSet];
1934
2121
  }
1935
2122
  const allDocs = docsData ? extractChannelDocsPayload(docsData) : [];
1936
2123
  const skills = allDocs.filter(isSkillDoc).map((d: any) => ({ doc_id: d.id, title: d.title }));
@@ -1975,7 +2162,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1975
2162
  // Verify by checking membership
1976
2163
  try {
1977
2164
  await new Promise(r => setTimeout(r, 500)); // wait for server to process
1978
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2165
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1979
2166
  if (r.ok) {
1980
2167
  const data = await r.json() as any;
1981
2168
  const isMember = (data.members || []).some((m: any) => m.agent_id === AGENT_ID);
@@ -1997,12 +2184,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1997
2184
  // fall through to WS leave_channel if REST is unreachable so existing
1998
2185
  // server-side WS handler still fires and updates in-memory state.
1999
2186
  try {
2000
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/leave`, {
2187
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/leave`, {
2001
2188
  method: "POST",
2002
2189
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2003
2190
  body: "{}",
2004
2191
  });
2005
2192
  if (r.ok) {
2193
+ // Prune so knownChannels / lastSeenMessageTs don't grow unbounded and
2194
+ // turn every reconnect backfill into a serial REST fetch over channels
2195
+ // we've already left.
2196
+ knownChannels.delete(chat_id);
2197
+ if (lastSeenMessageTs.delete(chat_id)) scheduleLastSeenMessageTsSave();
2006
2198
  const data = await r.json().catch(() => ({})) as any;
2007
2199
  if (data.note === "not a member") {
2008
2200
  return { content: [{ type: "text", text: `Already not a member of ${chat_id.slice(0, 8)}` }] };
@@ -2010,13 +2202,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2010
2202
  return { content: [{ type: "text", text: `Left channel ${chat_id.slice(0, 8)}` }] };
2011
2203
  }
2012
2204
  if (r.status === 404) {
2013
- return { content: [{ type: "text", text: `Channel ${chat_id.slice(0, 8)} not found` }] };
2205
+ return { content: [{ type: "text", text: `Channel ${chat_id.slice(0, 8)} not found` }], isError: true };
2014
2206
  }
2015
2207
  return { content: [{ type: "text", text: `Leave failed with status ${r.status}` }] };
2016
2208
  } catch (e: any) {
2017
2209
  // REST unreachable — fall back to WS leave so at least in-memory state updates
2018
2210
  if (ws && ws.readyState === WebSocket.OPEN) {
2019
2211
  try { ws.send(JSON.stringify({ type: "leave_channel", channel_id: chat_id, agent_id: AGENT_ID })); } catch {}
2212
+ knownChannels.delete(chat_id);
2213
+ if (lastSeenMessageTs.delete(chat_id)) scheduleLastSeenMessageTsSave();
2020
2214
  return { content: [{ type: "text", text: `Leave sent via WS (REST unreachable: ${String(e?.message || e).slice(0, 60)})` }] };
2021
2215
  }
2022
2216
  return { content: [{ type: "text", text: `Leave failed — no connectivity` }] };
@@ -2034,7 +2228,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2034
2228
  if (name === "hidden_identity_join") {
2035
2229
  const { game_id } = args as any;
2036
2230
  try {
2037
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/join`, {
2231
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/join`, {
2038
2232
  method: "POST",
2039
2233
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2040
2234
  body: "{}",
@@ -2047,7 +2241,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2047
2241
  const activeNote = channelId ? ` HI active mode enabled for channel ${String(channelId).slice(0, 8)}.` : "";
2048
2242
  return { content: [{ type: "text", text: `Joined game ${String(game_id).slice(0, 8)} — ${count} players in lobby.${activeNote}` }] };
2049
2243
  }
2050
- return { content: [{ type: "text", text: `Join failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
2244
+ return { content: [{ type: "text", text: `Join failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2051
2245
  } catch (e: any) {
2052
2246
  return { content: [{ type: "text", text: `Join failed: ${String(e?.message || e).slice(0, 80)}` }] };
2053
2247
  }
@@ -2056,7 +2250,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2056
2250
  if (name === "hidden_identity_get_secret") {
2057
2251
  const { game_id } = args as any;
2058
2252
  try {
2059
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/secret`, {
2253
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/secret`, {
2060
2254
  headers: { "Authorization": `Bearer ${TOKEN}` },
2061
2255
  });
2062
2256
  const data = await r.json().catch(() => ({})) as any;
@@ -2086,7 +2280,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2086
2280
  }
2087
2281
  if (r.status === 403) return { content: [{ type: "text", text: `You are not a player in this game (403)` }] };
2088
2282
  if (r.status === 404) return { content: [{ type: "text", text: `Game or secret not allocated yet (game may still be in lobby)` }] };
2089
- return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }] };
2283
+ return { content: [{ type: "text", text: `Secret fetch failed (${r.status})` }], isError: true };
2090
2284
  } catch (e: any) {
2091
2285
  return { content: [{ type: "text", text: `Secret fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2092
2286
  }
@@ -2097,7 +2291,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2097
2291
  try {
2098
2292
  const body: any = { target_id };
2099
2293
  if (typeof reason === "string" && reason) body.reason = reason;
2100
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/vote`, {
2294
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/vote`, {
2101
2295
  method: "POST",
2102
2296
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2103
2297
  body: JSON.stringify(body),
@@ -2106,7 +2300,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2106
2300
  if (r.ok) {
2107
2301
  return { content: [{ type: "text", text: `Vote cast against ${String(target_id).slice(0, 12)} in round ${data?.round}` }] };
2108
2302
  }
2109
- return { content: [{ type: "text", text: `Vote failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
2303
+ return { content: [{ type: "text", text: `Vote failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2110
2304
  } catch (e: any) {
2111
2305
  return { content: [{ type: "text", text: `Vote failed: ${String(e?.message || e).slice(0, 80)}` }] };
2112
2306
  }
@@ -2115,7 +2309,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2115
2309
  if (name === "hidden_identity_advance") {
2116
2310
  const { game_id, to } = args as any;
2117
2311
  try {
2118
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/advance`, {
2312
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}/advance`, {
2119
2313
  method: "POST",
2120
2314
  headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
2121
2315
  body: JSON.stringify({ to }),
@@ -2124,8 +2318,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2124
2318
  if (r.ok) {
2125
2319
  return { content: [{ type: "text", text: `Phase advanced to ${data?.phase || to}, round ${data?.round ?? "?"}` }] };
2126
2320
  }
2127
- if (r.status === 409) return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }] };
2128
- return { content: [{ type: "text", text: `Advance failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }] };
2321
+ if (r.status === 409) return { content: [{ type: "text", text: `Invalid transition to ${to} (409): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2322
+ return { content: [{ type: "text", text: `Advance failed (${r.status}): ${String(data?.error || "").slice(0, 120)}` }], isError: true };
2129
2323
  } catch (e: any) {
2130
2324
  return { content: [{ type: "text", text: `Advance failed: ${String(e?.message || e).slice(0, 80)}` }] };
2131
2325
  }
@@ -2134,7 +2328,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2134
2328
  if (name === "hidden_identity_get_state") {
2135
2329
  const { game_id } = args as any;
2136
2330
  try {
2137
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}`, {
2331
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(game_id)}`, {
2138
2332
  headers: { "Authorization": `Bearer ${TOKEN}` },
2139
2333
  });
2140
2334
  const data = await r.json().catch(() => ({})) as any;
@@ -2145,7 +2339,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2145
2339
  }).join(", ");
2146
2340
  return { content: [{ type: "text", text: `Phase: ${g.phase}, Round: ${g.round}, Winner: ${g.winner_team || "—"}. Players: ${players}` }] };
2147
2341
  }
2148
- return { content: [{ type: "text", text: `Game state fetch failed (${r.status})` }] };
2342
+ return { content: [{ type: "text", text: `Game state fetch failed (${r.status})` }], isError: true };
2149
2343
  } catch (e: any) {
2150
2344
  return { content: [{ type: "text", text: `Game state fetch failed: ${String(e?.message || e).slice(0, 80)}` }] };
2151
2345
  }
@@ -2153,6 +2347,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2153
2347
 
2154
2348
  if (name === "mark_read") {
2155
2349
  const { chat_id, last_read_id } = args as any;
2350
+ if (typeof chat_id !== "string" || typeof last_read_id !== "string") {
2351
+ return { content: [{ type: "text", text: "Error: chat_id and last_read_id (strings) required" }] };
2352
+ }
2156
2353
  if (ws && ws.readyState === WebSocket.OPEN) {
2157
2354
  ws.send(JSON.stringify({
2158
2355
  type: "read_receipt", channel_id: chat_id,
@@ -2168,7 +2365,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2168
2365
  let healthLine = "REST health: unknown";
2169
2366
  let authLine = "REST auth: unknown";
2170
2367
  try {
2171
- const r = await fetch(`${REST_URL}/health`);
2368
+ const r = await apiFetch(`${REST_URL}/health`);
2172
2369
  if (r.ok) {
2173
2370
  const h = await r.json() as any;
2174
2371
  const build = h?.build ? ` build=${h.build}` : "";
@@ -2181,7 +2378,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2181
2378
  healthLine = `REST health: error (${String(e?.message || e).slice(0, 80)})`;
2182
2379
  }
2183
2380
  try {
2184
- const r = await fetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2381
+ const r = await apiFetch(`${REST_URL}/api/account/${encodeURIComponent(AGENT_ID)}`, {
2185
2382
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
2186
2383
  });
2187
2384
  authLine = r.ok ? "REST auth: ok" : `REST auth: failed (${r.status})`;
@@ -2194,7 +2391,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2194
2391
  if (name === "list_channels") {
2195
2392
  const { limit = 50 } = args as any;
2196
2393
  try {
2197
- const r = await fetch(`${REST_URL}/api/channels/discover?limit=${Math.min(limit, 500)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2394
+ const r = await apiFetch(`${REST_URL}/api/channels/discover?limit=${Math.max(1, Math.min(Number(limit) || 50, 500))}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2198
2395
  if (r.ok) {
2199
2396
  const data = await r.json() as any;
2200
2397
  const channels = (data.channels || []);
@@ -2224,7 +2421,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2224
2421
  // DMs). DM channel ids are deterministic on iOS but the source of
2225
2422
  // truth for "does this DM exist between us" is server membership,
2226
2423
  // so we list + filter rather than replay the hash.
2227
- const r = await fetch(`${REST_URL}/api/channels/mine`, {
2424
+ const r = await apiFetch(`${REST_URL}/api/channels/mine`, {
2228
2425
  headers: { "Authorization": `Bearer ${TOKEN}` },
2229
2426
  });
2230
2427
  if (!r.ok) {
@@ -2237,7 +2434,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2237
2434
  for (const ch of channels) {
2238
2435
  if (ch?.type !== "direct") continue;
2239
2436
  try {
2240
- const mr = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(ch.id)}/members`, {
2437
+ const mr = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(ch.id)}/members`, {
2241
2438
  headers: { "Authorization": `Bearer ${TOKEN}` },
2242
2439
  });
2243
2440
  if (!mr.ok) continue;
@@ -2256,7 +2453,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2256
2453
 
2257
2454
  if (name === "list_loops") {
2258
2455
  try {
2259
- const r = await fetch(`${REST_URL}/api/loops/mine`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2456
+ const r = await apiFetch(`${REST_URL}/api/loops/mine`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2260
2457
  if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2261
2458
  const data = await r.json() as any;
2262
2459
  const loops = Array.isArray(data?.loops) ? data.loops : [];
@@ -2274,7 +2471,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2274
2471
 
2275
2472
  if (name === "my_entitlements") {
2276
2473
  try {
2277
- const r = await fetch(`${REST_URL}/api/me/entitlements`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2474
+ const r = await apiFetch(`${REST_URL}/api/me/entitlements`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2278
2475
  if (!r.ok) return { content: [{ type: "text", text: `Failed (${r.status})` }] };
2279
2476
  const data = await r.json() as any;
2280
2477
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
@@ -2293,7 +2490,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2293
2490
  if (name === "list_members") {
2294
2491
  const { chat_id } = args as any;
2295
2492
  try {
2296
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2493
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/members`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2297
2494
  if (r.ok) {
2298
2495
  const data = await r.json() as any;
2299
2496
  const members = data.members || [];
@@ -2310,7 +2507,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2310
2507
  if (name === "get_history") {
2311
2508
  const { chat_id, limit = 20 } = args as any;
2312
2509
  try {
2313
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages?limit=${Math.min(limit, 100)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2510
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages?limit=${Math.max(1, Math.min(Number(limit) || 20, 100))}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2314
2511
  if (r.ok) {
2315
2512
  const data = await r.json() as any;
2316
2513
  const msgs = (data.messages || []).filter((m: any) => m.content !== "__typing__");
@@ -2342,13 +2539,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2342
2539
  // Find and load the profile
2343
2540
  const targetFile = nameToPath(profile_name);
2344
2541
  if (!existsSync(targetFile)) {
2345
- return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }] };
2542
+ return { content: [{ type: "text", text: `Profile "${profile_name}" not found. Available: ${available.join(", ")}` }], isError: true };
2346
2543
  }
2347
2544
 
2348
2545
  const newProfile = JSON.parse(readFileSync(targetFile, "utf-8"));
2349
2546
 
2350
2547
  // Stop heartbeat first to prevent race with old connection
2351
2548
  heartbeat.stop();
2549
+ // Cancel any pending backfill / reconnect from the old identity.
2550
+ if (backfillTimer) { clearTimeout(backfillTimer); backfillTimer = null; }
2551
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2352
2552
 
2353
2553
  // Close old connection, disable its reconnect handler
2354
2554
  if (ws) {
@@ -2377,35 +2577,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2377
2577
  const qs = new URLSearchParams();
2378
2578
  const normalizedLevel = normalizeChannelDocLevel(level);
2379
2579
  if (level !== undefined && normalizedLevel === null) {
2380
- return { content: [{ type: "text", text: "list_channel_docs failed: level must be 1|2|3|4" }] };
2580
+ return { content: [{ type: "text", text: "list_channel_docs failed: level must be 1|2|3|4" }], isError: true };
2381
2581
  }
2382
2582
  if (normalizedLevel !== null) qs.set("level", String(normalizedLevel));
2383
2583
  const url = `${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs${qs.toString() ? `?${qs}` : ""}`;
2384
2584
  try {
2385
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2585
+ const r = await apiFetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2386
2586
  const text = await r.text();
2387
2587
  if (!r.ok) {
2388
- return { content: [{ type: "text", text: `list_channel_docs failed (${r.status}): ${text.slice(0, 200)}` }] };
2588
+ return { content: [{ type: "text", text: `list_channel_docs failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2389
2589
  }
2390
2590
  return { content: [{ type: "text", text }] };
2391
2591
  } catch (e: any) {
2392
- return { content: [{ type: "text", text: `list_channel_docs network error: ${String(e?.message || e).slice(0, 120)}` }] };
2592
+ return { content: [{ type: "text", text: `list_channel_docs network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2393
2593
  }
2394
2594
  }
2395
2595
 
2396
2596
  if (name === "get_channel_doc") {
2397
2597
  const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
2398
2598
  try {
2399
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2599
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2400
2600
  headers: { "Authorization": `Bearer ${TOKEN}` },
2401
2601
  });
2402
2602
  const text = await r.text();
2403
2603
  if (!r.ok) {
2404
- return { content: [{ type: "text", text: `get_channel_doc failed (${r.status}): ${text.slice(0, 200)}` }] };
2604
+ return { content: [{ type: "text", text: `get_channel_doc failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2405
2605
  }
2406
2606
  return { content: [{ type: "text", text }] };
2407
2607
  } catch (e: any) {
2408
- return { content: [{ type: "text", text: `get_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }] };
2608
+ return { content: [{ type: "text", text: `get_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2409
2609
  }
2410
2610
  }
2411
2611
 
@@ -2421,10 +2621,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2421
2621
  };
2422
2622
  const normalizedLevel = normalizeChannelDocLevel(level);
2423
2623
  if (normalizedLevel === null) {
2424
- return { content: [{ type: "text", text: "upsert_channel_doc failed: level must be 1|2|3|4" }] };
2624
+ return { content: [{ type: "text", text: "upsert_channel_doc failed: level must be 1|2|3|4" }], isError: true };
2425
2625
  }
2426
2626
  try {
2427
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2627
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}`, {
2428
2628
  method: "PUT",
2429
2629
  headers: {
2430
2630
  "Content-Type": "application/json",
@@ -2435,27 +2635,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2435
2635
  });
2436
2636
  const text = await r.text();
2437
2637
  if (!r.ok) {
2438
- return { content: [{ type: "text", text: `upsert_channel_doc failed (${r.status}): ${text.slice(0, 240)}` }] };
2638
+ return { content: [{ type: "text", text: `upsert_channel_doc failed (${r.status}): ${text.slice(0, 240)}` }], isError: true };
2439
2639
  }
2440
2640
  return { content: [{ type: "text", text }] };
2441
2641
  } catch (e: any) {
2442
- return { content: [{ type: "text", text: `upsert_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }] };
2642
+ return { content: [{ type: "text", text: `upsert_channel_doc network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2443
2643
  }
2444
2644
  }
2445
2645
 
2446
2646
  if (name === "list_channel_doc_revisions") {
2447
2647
  const { chat_id, doc_id } = args as { chat_id: string; doc_id: string };
2448
2648
  try {
2449
- const r = await fetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}/revisions`, {
2649
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/docs/${encodeURIComponent(doc_id)}/revisions`, {
2450
2650
  headers: { "Authorization": `Bearer ${TOKEN}` },
2451
2651
  });
2452
2652
  const text = await r.text();
2453
2653
  if (!r.ok) {
2454
- return { content: [{ type: "text", text: `list_channel_doc_revisions failed (${r.status}): ${text.slice(0, 200)}` }] };
2654
+ return { content: [{ type: "text", text: `list_channel_doc_revisions failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2455
2655
  }
2456
2656
  return { content: [{ type: "text", text }] };
2457
2657
  } catch (e: any) {
2458
- return { content: [{ type: "text", text: `list_channel_doc_revisions network error: ${String(e?.message || e).slice(0, 120)}` }] };
2658
+ return { content: [{ type: "text", text: `list_channel_doc_revisions network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2459
2659
  }
2460
2660
  }
2461
2661
 
@@ -2463,7 +2663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2463
2663
  // Maps 1:1 to the server-side routes shipped in commit 5229aab
2464
2664
  // (projects/AgentChat/Server/src/okr.ts + index.ts dispatch block).
2465
2665
  if (name === "okr_list") {
2466
- const { owner, status, horizon, include_archived, view, task_id } = args as { owner?: string; status?: string; horizon?: string; include_archived?: boolean; view?: string; task_id?: string };
2666
+ const { owner, status, horizon, include_archived, view, task_id, shape, objective_id } = args as { owner?: string; status?: string; horizon?: string; include_archived?: boolean; view?: string; task_id?: string; shape?: string; objective_id?: string };
2467
2667
  const qs = new URLSearchParams();
2468
2668
  if (owner) qs.set("owner", owner);
2469
2669
  if (status) qs.set("status", status);
@@ -2471,17 +2671,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2471
2671
  if (include_archived) qs.set("include_archived", "true");
2472
2672
  if (view) qs.set("view", view);
2473
2673
  if (task_id) qs.set("task_id", task_id);
2674
+ if (shape) qs.set("shape", shape);
2675
+ if (objective_id) qs.set("objective_id", objective_id);
2474
2676
  const url = `${REST_URL}/api/okr/objectives${qs.toString() ? "?" + qs.toString() : ""}`;
2475
2677
  try {
2476
- const r = await fetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2678
+ const r = await apiFetch(url, { headers: { "Authorization": `Bearer ${TOKEN}` } });
2477
2679
  if (!r.ok) {
2478
2680
  const err = await r.text();
2479
- return { content: [{ type: "text", text: `okr_list failed (${r.status}): ${err.slice(0, 120)}` }] };
2681
+ return { content: [{ type: "text", text: `okr_list failed (${r.status}): ${err.slice(0, 120)}` }], isError: true };
2480
2682
  }
2481
2683
  const data = await r.json() as any;
2482
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2684
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
2483
2685
  } catch (e: any) {
2484
- return { content: [{ type: "text", text: `okr_list network error: ${String(e?.message || e).slice(0, 120)}` }] };
2686
+ return { content: [{ type: "text", text: `okr_list network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2485
2687
  }
2486
2688
  }
2487
2689
 
@@ -2495,18 +2697,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2495
2697
  if (due) body.due = due;
2496
2698
  if (discussion_channel_id) body.discussion_channel_id = discussion_channel_id;
2497
2699
  try {
2498
- const r = await fetch(`${REST_URL}/api/okr/objectives`, {
2700
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives`, {
2499
2701
  method: "POST",
2500
2702
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2501
2703
  body: JSON.stringify(body),
2502
2704
  });
2503
2705
  const text = await r.text();
2504
2706
  if (!r.ok) {
2505
- return { content: [{ type: "text", text: `okr_create_objective failed (${r.status}): ${text.slice(0, 160)}` }] };
2707
+ return { content: [{ type: "text", text: `okr_create_objective failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2506
2708
  }
2507
2709
  return { content: [{ type: "text", text: `Created: ${text}` }] };
2508
2710
  } catch (e: any) {
2509
- return { content: [{ type: "text", text: `okr_create_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
2711
+ return { content: [{ type: "text", text: `okr_create_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2510
2712
  }
2511
2713
  }
2512
2714
 
@@ -2519,18 +2721,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2519
2721
  if (Array.isArray(depends_on) && depends_on.length > 0) body.depends_on = depends_on;
2520
2722
  if (due) body.due = due;
2521
2723
  try {
2522
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/tasks`, {
2724
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/tasks`, {
2523
2725
  method: "POST",
2524
2726
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2525
2727
  body: JSON.stringify(body),
2526
2728
  });
2527
2729
  const text = await r.text();
2528
2730
  if (!r.ok) {
2529
- return { content: [{ type: "text", text: `okr_add_task failed (${r.status}): ${text.slice(0, 160)}` }] };
2731
+ return { content: [{ type: "text", text: `okr_add_task failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2530
2732
  }
2531
2733
  return { content: [{ type: "text", text: `Added: ${text}` }] };
2532
2734
  } catch (e: any) {
2533
- return { content: [{ type: "text", text: `okr_add_task network error: ${String(e?.message || e).slice(0, 120)}` }] };
2735
+ return { content: [{ type: "text", text: `okr_add_task network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2534
2736
  }
2535
2737
  }
2536
2738
 
@@ -2544,18 +2746,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2544
2746
  if (Array.isArray(depends_on)) patch.depends_on = depends_on;
2545
2747
  if (due) patch.due = due;
2546
2748
  try {
2547
- const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}`, {
2749
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}`, {
2548
2750
  method: "PATCH",
2549
2751
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2550
2752
  body: JSON.stringify(patch),
2551
2753
  });
2552
2754
  const text = await r.text();
2553
2755
  if (!r.ok) {
2554
- return { content: [{ type: "text", text: `okr_update_task failed (${r.status}): ${text.slice(0, 160)}` }] };
2756
+ return { content: [{ type: "text", text: `okr_update_task failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2555
2757
  }
2556
2758
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2557
2759
  } catch (e: any) {
2558
- return { content: [{ type: "text", text: `okr_update_task network error: ${String(e?.message || e).slice(0, 120)}` }] };
2760
+ return { content: [{ type: "text", text: `okr_update_task network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2559
2761
  }
2560
2762
  }
2561
2763
 
@@ -2563,34 +2765,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2563
2765
  const { task_id } = args as { task_id: string };
2564
2766
  const path = name === "okr_task_blockers" ? "blockers" : "blocks";
2565
2767
  try {
2566
- const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/${path}`, {
2768
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/${path}`, {
2567
2769
  headers: { "Authorization": `Bearer ${TOKEN}` },
2568
2770
  });
2569
2771
  const text = await r.text();
2570
2772
  if (!r.ok) {
2571
- return { content: [{ type: "text", text: `${name} failed (${r.status}): ${text.slice(0, 160)}` }] };
2773
+ return { content: [{ type: "text", text: `${name} failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2572
2774
  }
2573
2775
  return { content: [{ type: "text", text }] };
2574
2776
  } catch (e: any) {
2575
- return { content: [{ type: "text", text: `${name} network error: ${String(e?.message || e).slice(0, 120)}` }] };
2777
+ return { content: [{ type: "text", text: `${name} network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2576
2778
  }
2577
2779
  }
2578
2780
 
2579
2781
  if (name === "okr_open_thread") {
2580
2782
  const { target_type, target_id } = args as { target_type: string; target_id: string };
2581
2783
  try {
2582
- const r = await fetch(`${REST_URL}/api/okr/threads`, {
2784
+ const r = await apiFetch(`${REST_URL}/api/okr/threads`, {
2583
2785
  method: "POST",
2584
2786
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2585
2787
  body: JSON.stringify({ target_type, target_id }),
2586
2788
  });
2587
2789
  const text = await r.text();
2588
2790
  if (!r.ok) {
2589
- return { content: [{ type: "text", text: `okr_open_thread failed (${r.status}): ${text.slice(0, 160)}` }] };
2791
+ return { content: [{ type: "text", text: `okr_open_thread failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2590
2792
  }
2591
2793
  return { content: [{ type: "text", text }] };
2592
2794
  } catch (e: any) {
2593
- return { content: [{ type: "text", text: `okr_open_thread network error: ${String(e?.message || e).slice(0, 120)}` }] };
2795
+ return { content: [{ type: "text", text: `okr_open_thread network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2594
2796
  }
2595
2797
  }
2596
2798
 
@@ -2602,53 +2804,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2602
2804
  if (typeof current === "number") body.current = current;
2603
2805
  if (risk_level) body.risk_level = risk_level;
2604
2806
  try {
2605
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/krs`, {
2807
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/krs`, {
2606
2808
  method: "POST",
2607
2809
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2608
2810
  body: JSON.stringify(body),
2609
2811
  });
2610
2812
  const text = await r.text();
2611
2813
  if (!r.ok) {
2612
- return { content: [{ type: "text", text: `okr_add_kr failed (${r.status}): ${text.slice(0, 160)}` }] };
2814
+ return { content: [{ type: "text", text: `okr_add_kr failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2613
2815
  }
2614
2816
  return { content: [{ type: "text", text: `Added: ${text}` }] };
2615
2817
  } catch (e: any) {
2616
- return { content: [{ type: "text", text: `okr_add_kr network error: ${String(e?.message || e).slice(0, 120)}` }] };
2818
+ return { content: [{ type: "text", text: `okr_add_kr network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2617
2819
  }
2618
2820
  }
2619
2821
 
2620
2822
  if (name === "archive_objective") {
2621
2823
  const { objective_id, completion_summary } = args as { objective_id: string; completion_summary?: string };
2622
2824
  try {
2623
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/archive`, {
2825
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/archive`, {
2624
2826
  method: "POST",
2625
2827
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2626
2828
  body: JSON.stringify(completion_summary !== undefined ? { completion_summary } : {}),
2627
2829
  });
2628
2830
  const text = await r.text();
2629
2831
  if (!r.ok) {
2630
- return { content: [{ type: "text", text: `archive_objective failed (${r.status}): ${text.slice(0, 200)}` }] };
2832
+ return { content: [{ type: "text", text: `archive_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2631
2833
  }
2632
2834
  return { content: [{ type: "text", text: `Archived: ${text}` }] };
2633
2835
  } catch (e: any) {
2634
- return { content: [{ type: "text", text: `archive_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
2836
+ return { content: [{ type: "text", text: `archive_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2635
2837
  }
2636
2838
  }
2637
2839
 
2638
2840
  if (name === "unarchive_objective") {
2639
2841
  const { objective_id } = args as { objective_id: string };
2640
2842
  try {
2641
- const r = await fetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/unarchive`, {
2843
+ const r = await apiFetch(`${REST_URL}/api/okr/objectives/${encodeURIComponent(objective_id)}/unarchive`, {
2642
2844
  method: "POST",
2643
2845
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2644
2846
  });
2645
2847
  const text = await r.text();
2646
2848
  if (!r.ok) {
2647
- return { content: [{ type: "text", text: `unarchive_objective failed (${r.status}): ${text.slice(0, 200)}` }] };
2849
+ return { content: [{ type: "text", text: `unarchive_objective failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2648
2850
  }
2649
2851
  return { content: [{ type: "text", text: `Unarchived: ${text}` }] };
2650
2852
  } catch (e: any) {
2651
- return { content: [{ type: "text", text: `unarchive_objective network error: ${String(e?.message || e).slice(0, 120)}` }] };
2853
+ return { content: [{ type: "text", text: `unarchive_objective network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2652
2854
  }
2653
2855
  }
2654
2856
 
@@ -2657,18 +2859,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2657
2859
  const body: Record<string, unknown> = { current };
2658
2860
  if (risk_level) body.risk_level = risk_level;
2659
2861
  try {
2660
- const r = await fetch(`${REST_URL}/api/okr/krs/${encodeURIComponent(kr_id)}/progress`, {
2862
+ const r = await apiFetch(`${REST_URL}/api/okr/krs/${encodeURIComponent(kr_id)}/progress`, {
2661
2863
  method: "PATCH",
2662
2864
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2663
2865
  body: JSON.stringify(body),
2664
2866
  });
2665
2867
  const text = await r.text();
2666
2868
  if (!r.ok) {
2667
- return { content: [{ type: "text", text: `okr_set_kr_progress failed (${r.status}): ${text.slice(0, 160)}` }] };
2869
+ return { content: [{ type: "text", text: `okr_set_kr_progress failed (${r.status}): ${text.slice(0, 160)}` }], isError: true };
2668
2870
  }
2669
2871
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2670
2872
  } catch (e: any) {
2671
- return { content: [{ type: "text", text: `okr_set_kr_progress network error: ${String(e?.message || e).slice(0, 120)}` }] };
2873
+ return { content: [{ type: "text", text: `okr_set_kr_progress network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2672
2874
  }
2673
2875
  }
2674
2876
 
@@ -2676,18 +2878,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2676
2878
  const { task_id, text: rawText } = args as { task_id: string; text: string };
2677
2879
  const text = redactSecrets(rawText);
2678
2880
  try {
2679
- const r = await fetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/comments`, {
2881
+ const r = await apiFetch(`${REST_URL}/api/okr/tasks/${encodeURIComponent(task_id)}/comments`, {
2680
2882
  method: "POST",
2681
2883
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2682
2884
  body: JSON.stringify({ text }),
2683
2885
  });
2684
2886
  const body = await r.text();
2685
2887
  if (!r.ok) {
2686
- return { content: [{ type: "text", text: `okr_add_task_comment failed (${r.status}): ${body.slice(0, 160)}` }] };
2888
+ return { content: [{ type: "text", text: `okr_add_task_comment failed (${r.status}): ${body.slice(0, 160)}` }], isError: true };
2687
2889
  }
2688
2890
  return { content: [{ type: "text", text: `Commented: ${body}` }] };
2689
2891
  } catch (e: any) {
2690
- return { content: [{ type: "text", text: `okr_add_task_comment network error: ${String(e?.message || e).slice(0, 120)}` }] };
2892
+ return { content: [{ type: "text", text: `okr_add_task_comment network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2691
2893
  }
2692
2894
  }
2693
2895
 
@@ -2708,22 +2910,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2708
2910
  if (linked_docs !== undefined) body.linked_docs = linked_docs;
2709
2911
  if (linked_channel_docs !== undefined) body.linked_channel_docs = linked_channel_docs;
2710
2912
  try {
2711
- const r = await fetch(`${REST_URL}/api/okr/links/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`, {
2913
+ const r = await apiFetch(`${REST_URL}/api/okr/links/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`, {
2712
2914
  method: "PATCH",
2713
2915
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
2714
2916
  body: JSON.stringify(body),
2715
2917
  });
2716
2918
  const text = await r.text();
2717
2919
  if (!r.ok) {
2718
- return { content: [{ type: "text", text: `okr_set_links failed (${r.status}): ${text.slice(0, 200)}` }] };
2920
+ return { content: [{ type: "text", text: `okr_set_links failed (${r.status}): ${text.slice(0, 200)}` }], isError: true };
2719
2921
  }
2720
2922
  return { content: [{ type: "text", text: `Updated: ${text}` }] };
2721
2923
  } catch (e: any) {
2722
- return { content: [{ type: "text", text: `okr_set_links network error: ${String(e?.message || e).slice(0, 120)}` }] };
2924
+ return { content: [{ type: "text", text: `okr_set_links network error: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
2723
2925
  }
2724
2926
  }
2725
2927
 
2726
- return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
2928
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2929
+ } catch (e: any) {
2930
+ return { content: [{ type: "text", text: `${name} failed: ${String(e?.message || e).slice(0, 300)}` }], isError: true };
2931
+ }
2727
2932
  });
2728
2933
 
2729
2934
  // --- WebSocket Connection ---
@@ -2733,13 +2938,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2733
2938
  const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
2734
2939
  function loadMentionTimestamps(): Map<string, string> {
2735
2940
  try {
2736
- const raw = require("fs").readFileSync(mentionTsFile, "utf-8");
2941
+ const raw = readFileSync(mentionTsFile, "utf-8");
2737
2942
  return new Map(Object.entries(JSON.parse(raw)));
2738
2943
  } catch { return new Map(); }
2739
2944
  }
2740
2945
  function saveMentionTimestamps(m: Map<string, string>) {
2741
2946
  try {
2742
- require("fs").writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2947
+ writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
2743
2948
  } catch {}
2744
2949
  }
2745
2950
  const lastMentionTimestamp = loadMentionTimestamps();
@@ -2762,13 +2967,13 @@ const lastMentionTimestamp = loadMentionTimestamps();
2762
2967
  const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
2763
2968
  function loadLastSeenMessageTs(): Map<string, string> {
2764
2969
  try {
2765
- const raw = require("fs").readFileSync(lastSeenMessageTsFile, "utf-8");
2970
+ const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
2766
2971
  return new Map(Object.entries(JSON.parse(raw)));
2767
2972
  } catch { return new Map(); }
2768
2973
  }
2769
2974
  function saveLastSeenMessageTs(m: Map<string, string>) {
2770
2975
  try {
2771
- require("fs").writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2976
+ writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
2772
2977
  } catch {}
2773
2978
  }
2774
2979
  const lastSeenMessageTs = loadLastSeenMessageTs();
@@ -2799,15 +3004,9 @@ function scheduleLastSeenMessageTsSave() {
2799
3004
  (lastSeenMessageTsTimer as any).unref?.();
2800
3005
  }
2801
3006
 
2802
- function normalizeTimestampForCursor(ts: string | undefined, mode: "before" | "after"): string | undefined {
2803
- if (!ts || typeof ts !== "string") return ts;
2804
- const m = ts.match(/^(.*\.)(\d+)(Z)$/);
2805
- if (!m) return ts;
2806
- const frac = m[2];
2807
- if (frac.length >= 9) return ts;
2808
- const padChar = mode === "before" ? "9" : "0";
2809
- return m[1] + frac + padChar.repeat(9 - frac.length) + m[3];
2810
- }
3007
+ // normalizeTimestampForCursor now lives in ./timestamps.ts (imported above),
3008
+ // where it also handles whole-second timestamps so lexical cursor comparison
3009
+ // matches chronological order.
2811
3010
 
2812
3011
  function normalizeChannelDocLevel(level: unknown): number | null {
2813
3012
  if (typeof level === "number" && Number.isInteger(level) && level >= 1 && level <= 4) {
@@ -2905,7 +3104,7 @@ function activateHiddenIdentityGame(gameId: string, channelId?: string) {
2905
3104
 
2906
3105
  async function fetchHiddenIdentityChannelId(gameId: string): Promise<string | undefined> {
2907
3106
  try {
2908
- const r = await fetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(gameId)}`, {
3107
+ const r = await apiFetch(`${REST_URL}/api/hidden-identity/games/${encodeURIComponent(gameId)}`, {
2909
3108
  headers: { "Authorization": `Bearer ${TOKEN}` },
2910
3109
  });
2911
3110
  if (!r.ok) return undefined;
@@ -2951,31 +3150,22 @@ function clearFinishedHiddenIdentityGamesFromMessage(data: any) {
2951
3150
  // legitimately receive the same persisted message once via live WS and once
2952
3151
  // via REST backfill; timestamp guards alone would either fail to drop that
2953
3152
  // duplicate or drop older out-of-order messages that were never processed.
2954
- const deliveredMessageIds = new Set<string>();
2955
- const MAX_DELIVERED_MESSAGE_IDS = 5000;
3153
+ // Dedup core (key derivation + bounded set) lives in ./dedup.ts; this file
3154
+ // keeps the thin logging wrapper so call sites and stderr output are unchanged.
3155
+ const messageDedup = new MessageDedup();
2956
3156
  function deliverySource(data: any): string {
2957
3157
  return typeof data?.__source === "string" ? data.__source : "live";
2958
3158
  }
2959
- function messageDedupKey(data: any): string | null {
2960
- if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string") return null;
2961
- return `${data.channel_id}:${data.id}`;
2962
- }
2963
3159
  function recordOrSkipDeliveredMessage(data: any): boolean {
2964
3160
  const key = messageDedupKey(data);
2965
3161
  if (!key) return false;
2966
- if (deliveredMessageIds.has(key)) {
3162
+ const skip = messageDedup.recordOrSkip(key);
3163
+ if (skip) {
2967
3164
  process.stderr.write(
2968
3165
  `[agentchat] Duplicate message skipped source=${deliverySource(data)} chat=${String(data.channel_id).slice(0, 12)} id=${String(data.id).slice(0, 12)}\n`,
2969
3166
  );
2970
- return true;
2971
- }
2972
- deliveredMessageIds.add(key);
2973
- if (deliveredMessageIds.size > MAX_DELIVERED_MESSAGE_IDS) {
2974
- const arr = [...deliveredMessageIds];
2975
- deliveredMessageIds.clear();
2976
- for (const item of arr.slice(1000)) deliveredMessageIds.add(item);
2977
3167
  }
2978
- return false;
3168
+ return skip;
2979
3169
  }
2980
3170
  // Channels we believe we're a member of. Populated from
2981
3171
  // `channel_created` events on auth_ok. Used as the backfill target set.
@@ -2986,12 +3176,11 @@ const knownChannels = new Set<string>();
2986
3176
  let currentHandleWSMessage: ((data: any) => Promise<void>) | null = null;
2987
3177
  let wsReconnectAttempt = 0;
2988
3178
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2989
- /** Flipped true when the server sends shard_moved (planned drain).
2990
- * ws.onclose checks + clears it so the close is treated as a clean
2991
- * hop, not a failure that advances the exponential backoff. */
2992
- let isPlannedReconnect = false;
3179
+ // Tracked so shutdown / switchIdentity can cancel a pending auth_ok backfill.
3180
+ let backfillTimer: ReturnType<typeof setTimeout> | null = null;
2993
3181
 
2994
3182
  function scheduleReconnect(delayMs: number) {
3183
+ if (shuttingDown) return; // don't resurrect the socket after shutdown
2995
3184
  if (reconnectTimer) clearTimeout(reconnectTimer);
2996
3185
  reconnectTimer = setTimeout(() => {
2997
3186
  reconnectTimer = null;
@@ -3024,12 +3213,29 @@ async function backfillAllChannels(): Promise<void> {
3024
3213
  const after = lastSeenMessageTs.get(channelId);
3025
3214
  const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
3026
3215
  const url = `${REST_URL}/api/channels/${encodeURIComponent(channelId)}/messages${params}`;
3027
- const res = await fetch(url, {
3216
+ const res = await apiFetch(url, {
3028
3217
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
3029
3218
  });
3030
3219
  if (!res.ok) continue;
3031
3220
  const data = await res.json() as { messages?: any[] };
3032
3221
  const msgs = data.messages || [];
3222
+ // No cursor yet (cold start / freshly joined channel): DON'T replay —
3223
+ // seed the cursor from the newest message so later backfills fetch only
3224
+ // genuinely new messages. Replaying here would surface a possibly
3225
+ // hours-old DM/@mention as a live notification (contract: empty cursor
3226
+ // = no backfill).
3227
+ if (!after) {
3228
+ let newestTs = "";
3229
+ for (const m of msgs) {
3230
+ const t = String(m?.timestamp || "");
3231
+ if (t > newestTs) newestTs = t;
3232
+ }
3233
+ if (newestTs) {
3234
+ lastSeenMessageTs.set(channelId, newestTs);
3235
+ scheduleLastSeenMessageTsSave();
3236
+ }
3237
+ continue;
3238
+ }
3033
3239
  // Filter out self-messages upfront (faster than re-entering the
3034
3240
  // handler just to bail) and typing placeholders (plugin ignores
3035
3241
  // them anyway).
@@ -3064,17 +3270,29 @@ async function backfillAllChannels(): Promise<void> {
3064
3270
  }
3065
3271
 
3066
3272
  function connectWS() {
3273
+ // Don't spin up a new socket after stdio shutdown — a stray reconnect
3274
+ // timer must never resurrect the connection.
3275
+ if (shuttingDown) return;
3276
+
3277
+ // Capture THIS socket locally. Every handler below guards on
3278
+ // `ws !== socket`, so a superseded socket (e.g. a dead-TCP connection
3279
+ // whose close event is delayed past the point a replacement is already
3280
+ // live) can never clobber the current session or schedule a duplicate
3281
+ // reconnect — the root cause of the orphan/double-connection race.
3282
+ let socket: WebSocket;
3067
3283
  try {
3068
- ws = new WebSocket(WS_URL);
3284
+ socket = new WebSocket(WS_URL);
3285
+ ws = socket;
3069
3286
  } catch (e) {
3070
3287
  process.stderr.write(`[agentchat] WebSocket constructor failed: ${e}, retrying in 5s\n`);
3071
- setTimeout(connectWS, 5000);
3288
+ scheduleReconnect(5000); // route through the single tracked timer
3072
3289
  return;
3073
3290
  }
3074
3291
 
3075
3292
  ws.onopen = () => {
3293
+ if (ws !== socket) return; // superseded by a newer socket
3076
3294
  try {
3077
- ws!.send(JSON.stringify({
3295
+ socket.send(JSON.stringify({
3078
3296
  type: "auth",
3079
3297
  agent_id: AGENT_ID,
3080
3298
  token: TOKEN,
@@ -3086,6 +3304,7 @@ function connectWS() {
3086
3304
  };
3087
3305
 
3088
3306
  ws.onmessage = async (event) => {
3307
+ if (ws !== socket) return; // ignore late frames from a superseded socket
3089
3308
  let data: any;
3090
3309
  try { data = JSON.parse(String(event.data)); } catch { return; }
3091
3310
  if (data && typeof data === "object" && !data.__source) data.__source = "live";
@@ -3118,9 +3337,16 @@ function connectWS() {
3118
3337
  // per-channel REST fetch and re-injects each missed message
3119
3338
  // through handleWSMessage so @mention detection + notification
3120
3339
  // path is identical to live delivery — no divergent code paths.
3121
- setTimeout(() => { void backfillAllChannels(); }, 2000);
3340
+ if (backfillTimer) clearTimeout(backfillTimer);
3341
+ backfillTimer = setTimeout(() => {
3342
+ backfillTimer = null;
3343
+ if (!shuttingDown) void backfillAllChannels();
3344
+ }, 2000);
3122
3345
  } else if (
3123
- data.type === "message" &&
3346
+ // thread_reply is a bidirectional protocol frame (Server -> Client too).
3347
+ // Route it through the same @mention / notification / cursor path as a
3348
+ // plain message, otherwise thread @mentions are silently invisible.
3349
+ (data.type === "message" || data.type === "thread_reply") &&
3124
3350
  // Loop ticks are server-fired with sender_id=loop.agent_id, which
3125
3351
  // equals AGENT_ID when the loop owner is THIS plugin. Without this
3126
3352
  // exception the outer "skip own messages" gate swallows every tick
@@ -3175,36 +3401,19 @@ function connectWS() {
3175
3401
  }
3176
3402
 
3177
3403
  const isDM = data.channel_id?.startsWith("dm-");
3178
- // Match both `@<agentId>` and `@<displayName>(<agentId>)` formats.
3179
- // v0.6.1 used a loose `content.includes("(" + AGENT_ID + ")")` for the
3180
- // second case which fired on ANY text containing `(<agentId>)` —
3181
- // including system messages like "User joined: name (acc_xyz)" or
3182
- // moderation logs. Boss msg:fc8b9b1a — codex agent received messages
3183
- // it wasn't @-mentioned in, ate context window. Tighten the second
3184
- // clause to require an `@<displayName>` immediately before `(<id>)`.
3185
- const idEsc = (AGENT_ID || "").replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3186
- const displayMentionRe = idEsc ? new RegExp(`@[^(\\n]+\\(${idEsc}\\)`) : null;
3187
- const isMentioned = !!(
3188
- data.content?.includes(`@${AGENT_ID}`) ||
3189
- (displayMentionRe && displayMentionRe.test(data.content || ""))
3190
- );
3404
+ // Match `@<agentId>` and `@<displayName>(<agentId>)`; see matchesMention
3405
+ // (mentions.ts) for why the second clause must not fire on incidental
3406
+ // `(<id>)` substrings like "User joined: name (acc_xyz)".
3407
+ const isMentioned = matchesMention(data.content || "", AGENT_ID || "");
3191
3408
  const activeHi = activeHiddenIdentityForChannel(data.channel_id);
3192
3409
 
3193
3410
  if (isDM || isMentioned || activeHi) {
3194
- // DM or @mention → respond
3195
- // 立即发送 typing ACK
3196
- if (isDM || isMentioned) {
3197
- try {
3198
- if (ws && ws.readyState === WebSocket.OPEN) {
3199
- ws.send(JSON.stringify({
3200
- type: "message", id: crypto.randomUUID(),
3201
- channel_id: data.channel_id, sender_id: AGENT_ID,
3202
- sender_type: "agent", content: "__typing__",
3203
- content_type: "text", timestamp: new Date().toISOString(),
3204
- }));
3205
- }
3206
- } catch {}
3207
- }
3411
+ // DM or @mention → respond. Start a cross-pod typing heartbeat so a human
3412
+ // on any pod sees the agent "thinking" for the whole processing duration.
3413
+ // The hub now cross-pod-broadcasts agent typing frames (cross_pod:true)
3414
+ // and never persists them, so this is ephemeral + zero-write. Stops when
3415
+ // we reply to the channel (reply handler), at the 120s cap, or on shutdown.
3416
+ if (isDM || isMentioned) startTypingHeartbeat(data.channel_id);
3208
3417
 
3209
3418
  // For @mention in channels, fetch context since last mention
3210
3419
  let contextPrefix = "";
@@ -3222,7 +3431,7 @@ function connectWS() {
3222
3431
  // membership for private). Without the Bearer header the
3223
3432
  // MCP agent would get 401/403 and answer the @mention
3224
3433
  // without any conversation context.
3225
- const historyRes = await fetch(historyUrl, {
3434
+ const historyRes = await apiFetch(historyUrl, {
3226
3435
  headers: TOKEN ? { "Authorization": `Bearer ${TOKEN}` } : {},
3227
3436
  });
3228
3437
  if (historyRes.ok) {
@@ -3243,7 +3452,7 @@ function connectWS() {
3243
3452
  const clipped = raw.length > maxPerMsg
3244
3453
  ? raw.slice(0, maxPerMsg) + " …[truncated]"
3245
3454
  : raw;
3246
- const size = clipped.length;
3455
+ const size = Buffer.byteLength(clipped, "utf8"); // real UTF-8 bytes (CJK is 3B/char, not 1)
3247
3456
  if (totalBytes + size > maxBytes) break;
3248
3457
  totalBytes += size;
3249
3458
  trimmed.unshift({ ...msgs[i], content: clipped });
@@ -3270,7 +3479,7 @@ function connectWS() {
3270
3479
  contextPrefix = `[HI游戏进行中 - 你是 game ${activeHi.gameId.slice(0, 8)} 的上桌玩家;此消息无需 @mention 也被实时推送。只在轮到你行动、需要讨论或需要投票时回复,否则可以旁观。]\n`;
3271
3480
  }
3272
3481
 
3273
- process.stderr.write(`[agentchat] ${isDM ? 'DM' : isMentioned ? '@mention' : 'HI-active'} from ${data.sender_id.slice(0, 8)}: ${data.content.slice(0, 50)}\n`);
3482
+ process.stderr.write(`[agentchat] ${isDM ? 'DM' : isMentioned ? '@mention' : 'HI-active'} from ${String(data.sender_id ?? "?").slice(0, 8)}: ${String(data.content ?? "").slice(0, 50)}\n`);
3274
3483
 
3275
3484
  // 推送给 Claude Code
3276
3485
  try {
@@ -3294,7 +3503,7 @@ function connectWS() {
3294
3503
  // Channel message without @mention → silent (just log)
3295
3504
  rateLimitedLog(
3296
3505
  "silent-channel-message",
3297
- `[agentchat] [silent] ${data.sender_id.slice(0, 8)} in ${data.channel_id.slice(0, 12)}: ${data.content.slice(0, 30)}\n`,
3506
+ `[agentchat] [silent] ${String(data.sender_id ?? "?").slice(0, 8)} in ${String(data.channel_id ?? "?").slice(0, 12)}: ${String(data.content ?? "").slice(0, 30)}\n`,
3298
3507
  );
3299
3508
  }
3300
3509
  } else if (data.type === "channel_created") {
@@ -3310,25 +3519,21 @@ function connectWS() {
3310
3519
  // Task #119: track channel id for reconnect backfill target set.
3311
3520
  if (typeof data.channel_id === "string") knownChannels.add(data.channel_id);
3312
3521
  } else if (data.type === "shard_moved") {
3313
- // Server instance shutting down or channel moved — reconnect
3314
- // immediately. This is a PLANNED disconnect: the server is
3315
- // giving us heads-up to move. Mark it so the upcoming
3316
- // ws.onclose doesn't treat this as a failure that increments
3317
- // wsReconnectAttempt / stretches the exponential backoff. When
3318
- // the server does a rolling deploy (several pods closing in
3319
- // sequence), without this flag each shard_moved would push the
3320
- // next reconnect 2s, 4s, 6s ... further out even though each
3321
- // is a clean planned event.
3522
+ // Server instance shutting down or channel moved — a PLANNED hop.
3523
+ // Detach the socket's onclose so it can't also run the unplanned
3524
+ // backoff path, reset the backoff counter, and fast-reconnect. This
3525
+ // keeps a rolling deploy (several pods closing in sequence) from
3526
+ // pushing each successive reconnect 2s, 4s, 6s ... further out.
3322
3527
  process.stderr.write(`[agentchat] Shard moved, reconnecting...\n`);
3323
3528
  if (data.redirect_url) {
3324
3529
  const newUrl = data.redirect_url.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
3325
3530
  process.stderr.write(`[agentchat] Redirecting to: ${newUrl}\n`);
3326
3531
  // Note: for simplicity we reconnect to original URL and let /api/shard handle routing
3327
3532
  }
3328
- isPlannedReconnect = true;
3329
- try { ws?.close(); } catch {}
3533
+ if (ws) { ws.onclose = null; try { ws.close(); } catch {} }
3330
3534
  ws = null;
3331
3535
  sessionId = null;
3536
+ wsReconnectAttempt = 0;
3332
3537
  scheduleReconnect(500);
3333
3538
  } else if (data.type === "error") {
3334
3539
  process.stderr.write(`[agentchat] Error: ${data.message}\n`);
@@ -3336,26 +3541,21 @@ function connectWS() {
3336
3541
  }
3337
3542
 
3338
3543
  ws.onclose = (event) => {
3544
+ if (ws !== socket) return; // a superseded socket's delayed close — leave live state alone
3339
3545
  sessionId = null;
3340
3546
  heartbeat.resetReconnecting(); // allow heartbeat to reconnect again if needed
3341
- // Planned reconnect (server-initiated shard_moved): reset backoff
3342
- // and reconnect fast, don't count this against exponential delay.
3343
- if (isPlannedReconnect) {
3344
- isPlannedReconnect = false;
3345
- wsReconnectAttempt = 0;
3346
- // A concurrent scheduleReconnect(500) from the shard_moved
3347
- // handler has already been queued — don't double-schedule.
3348
- process.stderr.write(`[agentchat] Planned close (code=${(event as any)?.code ?? "?"}), reconnect in 0.5s\n`);
3349
- return;
3350
- }
3547
+ // Only genuine UNPLANNED drops reach here now. Planned paths
3548
+ // (shard_moved / heartbeat.reconnect / switchIdentity) detach this
3549
+ // handler and self-schedule a fast reconnect, so there is no
3550
+ // isPlannedReconnect flag to consume and no fast/slow ambiguity.
3351
3551
  wsReconnectAttempt++;
3352
- const jitter = Math.random() * 3000; // 0-3s random jitter to avoid thundering herd
3353
- const delay = Math.min(wsReconnectAttempt * 2, 30) * 1000 + jitter;
3552
+ const delay = computeReconnectDelay(wsReconnectAttempt);
3354
3553
  process.stderr.write(`[agentchat] Disconnected (code=${(event as any)?.code ?? "?"}), reconnecting in ${Math.round(delay/100)/10}s (attempt ${wsReconnectAttempt})...\n`);
3355
3554
  scheduleReconnect(delay);
3356
3555
  };
3357
3556
 
3358
3557
  ws.onerror = (err) => {
3558
+ if (ws !== socket) return;
3359
3559
  process.stderr.write(`[agentchat] WebSocket error: ${err}\n`);
3360
3560
  };
3361
3561
  }
@@ -3369,7 +3569,9 @@ const heartbeat = new HeartbeatMonitor({
3369
3569
  },
3370
3570
  reconnect: () => {
3371
3571
  process.stderr.write("[agentchat] Heartbeat timeout, forcing reconnect\n");
3372
- try { ws?.close(); } catch {}
3572
+ // Detach onclose so the forced close doesn't also run the unplanned
3573
+ // backoff path and downgrade this fast 500ms recovery to 2-5s.
3574
+ if (ws) { ws.onclose = null; try { ws.close(); } catch {} }
3373
3575
  ws = null;
3374
3576
  sessionId = null;
3375
3577
  wsReconnectAttempt = 0; // reset backoff for heartbeat-triggered reconnect
@@ -3385,10 +3587,15 @@ function shutdownFromStdio(reason: string) {
3385
3587
  safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
3386
3588
  try { flushLastSeenMessageTs(); } catch {}
3387
3589
  try { heartbeat.stop(); } catch {}
3590
+ try { stopAllTypingHeartbeats(); } catch {}
3388
3591
  if (reconnectTimer) {
3389
3592
  clearTimeout(reconnectTimer);
3390
3593
  reconnectTimer = null;
3391
3594
  }
3595
+ if (backfillTimer) {
3596
+ clearTimeout(backfillTimer);
3597
+ backfillTimer = null;
3598
+ }
3392
3599
  try { ws?.close(); } catch {}
3393
3600
  ws = null;
3394
3601
  sessionId = null;