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