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