@sleep2agi/commhub-server 0.9.0-preview.31 → 0.9.0-preview.33
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/package.json +1 -1
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +10 -0
- package/src/create-node-validate.ts +5 -3
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +58 -1
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message.test.ts +222 -0
- package/src/observer-avatar-http.test.ts +75 -0
- package/src/observer-push.test.ts +76 -0
- package/src/push.ts +53 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/server.ts +49 -5
- package/src/tools.ts +144 -17
package/src/tools.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
2
3
|
import { z } from "zod/v4";
|
|
3
4
|
import { parseAliasFilter } from "./alias-filter.js";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
6
|
-
import { getSSEStats, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
7
|
+
import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
|
|
7
8
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
8
9
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
9
10
|
import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
|
|
@@ -1429,12 +1430,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1429
1430
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
1430
1431
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
1431
1432
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1433
|
+
// 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
|
|
1434
|
+
// 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
|
|
1435
|
+
// 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
|
|
1436
|
+
// 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
|
|
1437
|
+
// unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
|
|
1438
|
+
// 任务标了 delivered,会话却从未收到推送)。
|
|
1439
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
1440
|
+
const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
|
|
1435
1441
|
// #461 network observer summary — unconditional (task row exists
|
|
1436
1442
|
// even when the target is offline/queued), metadata only.
|
|
1437
|
-
pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status:
|
|
1443
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
|
|
1438
1444
|
|
|
1439
1445
|
if (target.state === "offline") {
|
|
1440
1446
|
return {
|
|
@@ -1502,9 +1508,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1502
1508
|
[id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
|
|
1503
1509
|
);
|
|
1504
1510
|
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
}
|
|
1511
|
+
// 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
|
|
1512
|
+
pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
1508
1513
|
|
|
1509
1514
|
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
1510
1515
|
if (offlineReply) {
|
|
@@ -1534,7 +1539,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1534
1539
|
|
|
1535
1540
|
// ── V2/V3 reply primitives ──
|
|
1536
1541
|
const replyToolSchema = {
|
|
1537
|
-
alias:
|
|
1542
|
+
// #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
|
|
1543
|
+
// is set, the hub derives the target from the original task's
|
|
1544
|
+
// `from_name` (it already SELECTs that task by in_reply_to below).
|
|
1545
|
+
// The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
|
|
1546
|
+
// send_reply as (task_id, text, status) with NO alias, so before this
|
|
1547
|
+
// it always sent alias=undefined → -32602 "expected string, received
|
|
1548
|
+
// undefined at alias". The target was already knowable from the task,
|
|
1549
|
+
// so requiring the caller to pass it was a redundant-param trap.
|
|
1550
|
+
alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
|
|
1538
1551
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
1539
1552
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
1540
1553
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
@@ -1585,9 +1598,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1585
1598
|
: meta;
|
|
1586
1599
|
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1587
1600
|
|
|
1588
|
-
|
|
1601
|
+
// #1085 — derive the reply target when the caller omitted `alias`.
|
|
1602
|
+
// The original task's `from_name` IS the reply target; the hub knows
|
|
1603
|
+
// it from `in_reply_to`. Without this, an SDK-runtime send_reply
|
|
1604
|
+
// (schema has no alias) died with -32602 before reaching this handler.
|
|
1605
|
+
let effectiveAlias: string | undefined =
|
|
1606
|
+
(typeof alias === "string" && alias.trim()) ? alias : undefined;
|
|
1607
|
+
if (!effectiveAlias) {
|
|
1608
|
+
if (!in_reply_to) {
|
|
1609
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_required", message: "alias omitted and no in_reply_to to derive it from" }) }] };
|
|
1610
|
+
}
|
|
1611
|
+
const derivParams: any[] = [in_reply_to];
|
|
1612
|
+
let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
|
|
1613
|
+
derivSql = addScope(derivSql, derivParams, effectiveNetId);
|
|
1614
|
+
const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
|
|
1615
|
+
if (!derived?.from_name) {
|
|
1616
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_unresolved", message: `alias omitted and could not derive target from in_reply_to (${in_reply_to})` }) }] };
|
|
1617
|
+
}
|
|
1618
|
+
effectiveAlias = derived.from_name;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
|
|
1589
1622
|
const replyTargetAlias = canonicalReplyTarget.alias;
|
|
1590
|
-
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${
|
|
1623
|
+
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${effectiveAlias}]` : ""}`);
|
|
1591
1624
|
const id = uuidv4();
|
|
1592
1625
|
const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
|
|
1593
1626
|
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
|
|
@@ -2148,6 +2181,97 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2148
2181
|
}
|
|
2149
2182
|
);
|
|
2150
2183
|
|
|
2184
|
+
server.tool(
|
|
2185
|
+
"send_desktop_message",
|
|
2186
|
+
"Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
|
|
2187
|
+
{
|
|
2188
|
+
to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
|
|
2189
|
+
to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
|
|
2190
|
+
title: z.string().max(200).optional(),
|
|
2191
|
+
message: z.string().min(1).max(10000),
|
|
2192
|
+
severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
|
|
2193
|
+
kind: z.string().min(1).max(80).optional().default("agent_message"),
|
|
2194
|
+
meta: z.record(z.unknown()).optional(),
|
|
2195
|
+
from_session: z.string().max(200).optional(),
|
|
2196
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
|
|
2197
|
+
},
|
|
2198
|
+
async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
|
|
2199
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn);
|
|
2200
|
+
if (fromMismatch) return fromMismatch;
|
|
2201
|
+
const from_session = defaultFrom(_fromIn);
|
|
2202
|
+
const effectiveNetId = getNetworkId(netId);
|
|
2203
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2204
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
2205
|
+
|
|
2206
|
+
if (!to_user_id && !to_username) {
|
|
2207
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const byId = to_user_id
|
|
2211
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
|
|
2212
|
+
: null;
|
|
2213
|
+
const byName = to_username
|
|
2214
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
|
|
2215
|
+
: null;
|
|
2216
|
+
|
|
2217
|
+
if (to_user_id && !byId) {
|
|
2218
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
|
|
2219
|
+
}
|
|
2220
|
+
if (to_username && !byName) {
|
|
2221
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
|
|
2222
|
+
}
|
|
2223
|
+
if (byId && byName && byId.user_id !== byName.user_id) {
|
|
2224
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
const target = byId ?? byName;
|
|
2228
|
+
if (!target) {
|
|
2229
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
|
|
2233
|
+
if (!targetRole) {
|
|
2234
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
const messageId = `dm_${uuidv4()}`;
|
|
2238
|
+
const event = {
|
|
2239
|
+
type: "desktop_message",
|
|
2240
|
+
message_id: messageId,
|
|
2241
|
+
kind,
|
|
2242
|
+
from: from_session,
|
|
2243
|
+
title: title || null,
|
|
2244
|
+
message,
|
|
2245
|
+
severity,
|
|
2246
|
+
created_at: new Date().toISOString(),
|
|
2247
|
+
...(meta && Object.keys(meta).length ? { meta } : {}),
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
db.run(
|
|
2251
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
2252
|
+
VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
|
|
2253
|
+
[
|
|
2254
|
+
enforceUserId || null,
|
|
2255
|
+
callerAlias || null,
|
|
2256
|
+
target.user_id,
|
|
2257
|
+
JSON.stringify({
|
|
2258
|
+
message_id: messageId,
|
|
2259
|
+
to_username: target.username,
|
|
2260
|
+
from: from_session,
|
|
2261
|
+
severity,
|
|
2262
|
+
kind,
|
|
2263
|
+
title: title || null,
|
|
2264
|
+
meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
|
|
2265
|
+
}),
|
|
2266
|
+
effectiveNetId,
|
|
2267
|
+
],
|
|
2268
|
+
);
|
|
2269
|
+
pushUserEvent(effectiveNetId, target.user_id, event);
|
|
2270
|
+
|
|
2271
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, message_id: messageId, delivered_to_user_id: target.user_id, network_id: effectiveNetId }) }] };
|
|
2272
|
+
},
|
|
2273
|
+
);
|
|
2274
|
+
|
|
2151
2275
|
server.tool(
|
|
2152
2276
|
"broadcast",
|
|
2153
2277
|
"Send a message to multiple sessions.",
|
|
@@ -2777,7 +2901,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2777
2901
|
// schema-promoted columns runtimes_supported/allowed_secret_keys
|
|
2778
2902
|
// are pre-extracted but role isn't a first-class column).
|
|
2779
2903
|
const nowMs = Date.now();
|
|
2780
|
-
|
|
2904
|
+
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
|
2905
|
+
// 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
|
|
2906
|
+
// (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
|
|
2907
|
+
const ONLINE_MS = 5 * 60_000;
|
|
2781
2908
|
const daemons = rows
|
|
2782
2909
|
.map(r => {
|
|
2783
2910
|
let snapRole: string | null = null;
|
|
@@ -2796,7 +2923,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2796
2923
|
let lastSeenAt: string | null = null;
|
|
2797
2924
|
if (r.session_last_seen) {
|
|
2798
2925
|
lastSeenAt = r.session_last_seen;
|
|
2799
|
-
const t =
|
|
2926
|
+
const t = parseDbTimestampMs(r.session_last_seen);
|
|
2800
2927
|
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
2801
2928
|
}
|
|
2802
2929
|
// Parse self-declare arrays (default to [] for pre-PR2 daemons)
|
|
@@ -2850,7 +2977,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2850
2977
|
node_spec: z.object({
|
|
2851
2978
|
name: z.string().min(1).max(64),
|
|
2852
2979
|
runtime: z.string().min(1).max(64),
|
|
2853
|
-
model: z.string().min(1).max(100),
|
|
2980
|
+
model: z.string().min(1).max(100).optional().nullable(),
|
|
2854
2981
|
flags: z.record(z.unknown()).optional(),
|
|
2855
2982
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
2856
2983
|
channels: z.array(z.unknown()).optional(),
|
|
@@ -3006,7 +3133,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3006
3133
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
3007
3134
|
[
|
|
3008
3135
|
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
3009
|
-
node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
|
|
3136
|
+
node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
|
|
3010
3137
|
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
3011
3138
|
],
|
|
3012
3139
|
);
|
|
@@ -3026,7 +3153,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3026
3153
|
daemon_node_id,
|
|
3027
3154
|
child_name: node_spec.name,
|
|
3028
3155
|
runtime: node_spec.runtime,
|
|
3029
|
-
model: node_spec.model,
|
|
3156
|
+
model: node_spec.model ?? null,
|
|
3030
3157
|
flag_keys: Object.keys(node_spec.flags || {}),
|
|
3031
3158
|
env_keys: envKeys,
|
|
3032
3159
|
},
|
|
@@ -3090,7 +3217,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3090
3217
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
3091
3218
|
}
|
|
3092
3219
|
// Hydrate spec from row.
|
|
3093
|
-
const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
|
|
3220
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
|
|
3094
3221
|
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
3095
3222
|
request_id,
|
|
3096
3223
|
);
|