@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/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
- if (target.state === "online") {
1433
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
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: target.state === "online" ? "delivered" : "queued", priority });
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
- if (target.state === "online") {
1506
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
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: z.string().min(1).max(200).describe("Target session 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
- const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, alias);
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 ${alias}]` : ""}`);
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
- const ONLINE_MS = 60_000;
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 = Date.parse(r.session_last_seen);
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
  );