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