@sleep2agi/commhub-server 0.9.0-preview.22 → 0.9.0-preview.24

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/server.ts CHANGED
@@ -3,9 +3,10 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
- import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats } from "./push.js";
6
+ import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
7
7
  import { assertNodeActive } from "./lifecycle-guard.js";
8
8
  import { validateAvatarUrl } from "./avatar-validate.js";
9
+ import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
9
10
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
10
11
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
11
12
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
@@ -213,6 +214,45 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
213
214
  return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
214
215
  }
215
216
 
217
+ // #495 — shared authorization gate for /api/files/:file_id downloads.
218
+ // Called from the GET handler and any future HEAD/Range/dashboard-proxy
219
+ // entry so the allow-list can't drift between methods (通信龙 clause 2:
220
+ // "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
221
+ //
222
+ // Returns true if the caller may read this entry; false otherwise. The
223
+ // caller emits 404 on false — never 403; 403 would leak that the
224
+ // file_id exists and enables enumeration.
225
+ //
226
+ // Allow-list (staged P0 hotfix; same-network non-owner is NOT included
227
+ // here — see follow-up #503):
228
+ // - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
229
+ // - admin utok_ → allow (mirrors requireAdminAuth).
230
+ // - Owner match: caller's resolved user_id === entry.owner_id.
231
+ // - null owner_id + DEV_OPEN → allow. DEV_OPEN uploads carry
232
+ // owner_id=null (authCtx=null in DEV_OPEN mode); rejecting them
233
+ // would break the intended local-dev anonymous flow. Encoded as
234
+ // an explicit env-gated branch so if DEV_OPEN semantics change,
235
+ // the code fails loudly instead of trusting a stale note.
236
+ // - Everything else → deny (404). New uploads in production always
237
+ // carry a truthy owner_id (requireAuth blocks the null-owner-
238
+ // producing path when DEV_OPEN=off and no legacy master token is
239
+ // configured), so denying null-owner in production closes the
240
+ // enumeration vector for legacy blobs without breaking new uploads.
241
+ export function authorizeFileDownload(req: Request, entry: { owner_id?: unknown }): boolean {
242
+ const token = requestToken(req);
243
+ const authCtx = resolveRequestAuth(req);
244
+ const resolved = token ? resolveToken(token) : null;
245
+ const isLegacyMasterCaller = !authCtx && isLegacyAuthToken(req);
246
+ const isAdminCaller = !!resolved && resolved.user.role === "admin";
247
+ const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
248
+ ? entry.owner_id
249
+ : null;
250
+ const isOwnerCaller = !!authCtx && !!ownerId && authCtx.userId === ownerId;
251
+ if (isLegacyMasterCaller || isAdminCaller || isOwnerCaller) return true;
252
+ if (ownerId === null && DEV_OPEN) return true;
253
+ return false;
254
+ }
255
+
216
256
  type RestNetworkScope = {
217
257
  networkId: string | null;
218
258
  networkIds: string[] | null;
@@ -1157,10 +1197,86 @@ return Bun.serve({
1157
1197
  // now lives behind auth at GET /api/stats/sse.
1158
1198
  // `version` stays: it does help fingerprinting, but ops needs it
1159
1199
  // to verify deploys land, and the tradeoff was accepted in review.
1200
+ //
1201
+ // #495-followup / f28a6c1b — auth-gated `sse_sessions` restored so
1202
+ // the dashboard's own "online" widget can compute per-agent status
1203
+ // in one round-trip. Rules:
1204
+ // - anonymous (no valid token): NO sse_sessions field → watchdog
1205
+ // contract untouched, no topology leak (mutation removing this
1206
+ // gate turns the anonymous test RED — see health-redaction.test)
1207
+ // - admin utok_ / legacy master / DEV_OPEN: full sessions map
1208
+ // (parity with /api/stats/sse ops path)
1209
+ // - regular utok_ member: sessions map FILTERED to network_ids
1210
+ // the caller is a member of — safe against the "any account
1211
+ // sees all-network topology" regression the #473 fix closed;
1212
+ // dashboard user proxies with the logged-in user's utok_ so
1213
+ // their own network's aliases surface; strangers' networks do
1214
+ // not
1160
1215
  const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
1161
1216
  const sse = getSSEStats();
1162
1217
  const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
1163
- return withCors(req, Response.json({
1218
+
1219
+ // Non-fatal auth probe (no 401 — /health must stay 200 anonymous).
1220
+ const healthAuth = resolveRequestAuth(req);
1221
+ const healthIsMaster = !healthAuth && isLegacyAuthToken(req);
1222
+ const healthIsDevOpen = !healthAuth && !AUTH_TOKEN && DEV_OPEN;
1223
+ const healthIsAdmin = !!(healthAuth?.username && db.get<any>(
1224
+ "SELECT role FROM users WHERE username = ?1", healthAuth.username,
1225
+ )?.role === "admin");
1226
+
1227
+ let scopedSessions: Record<string, number> | undefined;
1228
+ if (healthIsMaster || healthIsDevOpen || healthIsAdmin) {
1229
+ // Ops parity with /api/stats/sse — full map.
1230
+ scopedSessions = sse.sessions;
1231
+ } else if (healthAuth?.userId) {
1232
+ // Regular authenticated member — filter to networks they belong
1233
+ // to. Keys are `{networkId}:{alias}` (or observer keys shaped
1234
+ // `\0netobs:{networkId}`); parse the network prefix and keep
1235
+ // only entries whose network is in the member's set.
1236
+ //
1237
+ // 🔴 ntok_ (network-scoped token) MUST be forced to its single
1238
+ // bound network (parity with resolveRestNetworkScope L277:
1239
+ // `if (authCtx.networkId) return { networkId: authCtx.networkId }`).
1240
+ // Falling through to the utok_ union query would return
1241
+ // sessions from every network the underlying user belongs to,
1242
+ // silently escalating a network-scoped token to a user-scoped
1243
+ // token — the exact "认证 ≠ 已授权" mistake this whole change
1244
+ // is meant to prevent. Same shape as f28a6c1b root cause; see
1245
+ // CR3 audit 4703b0e7.
1246
+ const memberNets = new Set<string>(
1247
+ healthAuth.networkId
1248
+ ? [healthAuth.networkId] // ntok_ single-network enforce
1249
+ : db.all<{ network_id: string }>(
1250
+ "SELECT network_id FROM network_members WHERE user_id = ?1",
1251
+ healthAuth.userId,
1252
+ ).map((r) => r.network_id),
1253
+ );
1254
+ // If member has no networks at all, keep field present but empty
1255
+ // so the dashboard can distinguish "authenticated + none active"
1256
+ // from "not authenticated at all".
1257
+ const filtered: Record<string, number> = {};
1258
+ for (const [key, n] of Object.entries(sse.sessions)) {
1259
+ // Observer keys are shape `\0netobs:<networkId>` raw; keyed
1260
+ // through `printableKey()` in push.ts, they surface here as
1261
+ // the literal string `\\0netobs:<networkId>` (backslash-zero,
1262
+ // not NUL byte). Use the shared `PRINTABLE_OBSERVER_KEY_PREFIX`
1263
+ // export so this side and the emitter side stay locked to the
1264
+ // same literal — CR3 audit 4703b0e7 caught prior check
1265
+ // `startsWith("netobs:")` was always false → observer keys
1266
+ // silently over-filtered out of member views.
1267
+ const isObserver = key.startsWith(PRINTABLE_OBSERVER_KEY_PREFIX);
1268
+ const netId = isObserver
1269
+ ? key.slice(PRINTABLE_OBSERVER_KEY_PREFIX.length)
1270
+ : key.split(":")[0];
1271
+ if (memberNets.has(netId)) filtered[key] = n;
1272
+ }
1273
+ scopedSessions = filtered;
1274
+ }
1275
+ // healthAuth == null AND no legacy master AND no DEV_OPEN → leave
1276
+ // scopedSessions undefined → sse_sessions field omitted entirely
1277
+ // (watchdog + arbitrary internet strangers see aggregates only).
1278
+
1279
+ const body: Record<string, unknown> = {
1164
1280
  ok: true,
1165
1281
  version: SERVER_VERSION,
1166
1282
  api_version: "v3",
@@ -1174,7 +1290,9 @@ return Bun.serve({
1174
1290
  multi_network: true,
1175
1291
  license: license?.type || "none",
1176
1292
  uptime: Math.floor(process.uptime()),
1177
- }));
1293
+ };
1294
+ if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
1295
+ return withCors(req, Response.json(body));
1178
1296
  }
1179
1297
 
1180
1298
  // ── All REST /api endpoints require auth (if token configured) ──
@@ -1619,14 +1737,27 @@ return Bun.serve({
1619
1737
  }));
1620
1738
  }
1621
1739
 
1622
- // ── REST: file download (#221) ──
1623
- // GET /api/files/:file_id with Bearer auth. Always forces
1624
- // Content-Disposition: attachment + X-Content-Type-Options: nosniff
1625
- // so the served file can never be executed or rendered as HTML.
1626
- // The file_id is validated against the same strict regex used at
1627
- // generation time before any filesystem access.
1740
+ // ── REST: file download (#221 + #495 + #500 CR2 HEAD) ──
1741
+ // GET or HEAD /api/files/:file_id with Bearer auth + ownership
1742
+ // check. Always forces Content-Disposition: attachment +
1743
+ // X-Content-Type-Options: nosniff so the served file can never be
1744
+ // executed or rendered as HTML. The file_id is validated against
1745
+ // the same strict regex used at generation time before any
1746
+ // filesystem access.
1747
+ //
1748
+ // Ownership gate is factored into `authorizeFileDownload` (see
1749
+ // src/server.ts near resolveRequestAuth). HEAD MUST route through
1750
+ // the same helper — pre-#500-CR2, HEAD fell through to a fallback
1751
+ // 200 page whose body happened to be byte-identical to the
1752
+ // "unknown file" case, so no enumeration oracle formed in practice.
1753
+ // That was coincidence, not a gate. A change to the fallback (e.g.
1754
+ // adding "Requested resource: <hint>") would materialize the oracle
1755
+ // with zero failing tests. Any future Range, conditional-request,
1756
+ // or dashboard-proxy path for /api/files MUST route through this
1757
+ // handler (via authorizeFileDownload) — do not duplicate the
1758
+ // allow-list inline, or the branches will drift.
1628
1759
  const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
1629
- if (fileMatch && req.method === "GET") {
1760
+ if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
1630
1761
  const authErr = requireAuth(req);
1631
1762
  if (authErr) return withCors(req, authErr);
1632
1763
 
@@ -1648,6 +1779,15 @@ return Bun.serve({
1648
1779
  return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1649
1780
  }
1650
1781
 
1782
+ // #495 ownership gate — placed AFTER file-exists so denied
1783
+ // callers cannot distinguish "you don't own this" from
1784
+ // "no such file". Both branches return the same 404 shape
1785
+ // for BOTH GET and HEAD (HEAD honours body-omission but the
1786
+ // status code and headers are the authoritative signal).
1787
+ if (!authorizeFileDownload(req, entry)) {
1788
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1789
+ }
1790
+
1651
1791
  let storage;
1652
1792
  try {
1653
1793
  storage = buildStoragePath(entry.file_id, entry.ext);
@@ -1675,7 +1815,6 @@ return Bun.serve({
1675
1815
  const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
1676
1816
  ? entry.name
1677
1817
  : `${fileId}${entry.ext}`;
1678
- const blob = Bun.file(storage.absolutePath);
1679
1818
  const responseHeaders: Record<string, string> = {
1680
1819
  ...corsHeaders(req),
1681
1820
  "Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
@@ -1684,6 +1823,15 @@ return Bun.serve({
1684
1823
  "X-Content-Type-Options": "nosniff",
1685
1824
  "Cache-Control": "private, no-store",
1686
1825
  };
1826
+ // HEAD: return the same status and headers as GET, but no body,
1827
+ // per RFC 9110 §9.3.2. This ensures HEAD passes through the same
1828
+ // authorization helper — a cross-owner HEAD is 404 (same as GET),
1829
+ // not a fallback 200. Status/headers are the authoritative
1830
+ // signal; body omission is HTTP-standard for HEAD.
1831
+ if (req.method === "HEAD") {
1832
+ return new Response(null, { status: 200, headers: responseHeaders });
1833
+ }
1834
+ const blob = Bun.file(storage.absolutePath);
1687
1835
  return new Response(blob, { status: 200, headers: responseHeaders });
1688
1836
  }
1689
1837
 
@@ -2129,6 +2277,115 @@ return Bun.serve({
2129
2277
  // legacy master token). Value passes validateAvatarUrl (http/https
2130
2278
  // only, ≤ 2048 chars, no control chars — XSS boundary, see
2131
2279
  // avatar-validate.ts).
2280
+ // ── REST: node DISPLAY attributes (display_name / team / tags) ──
2281
+ // Companion to the avatar route below: hub-side presentation fields that
2282
+ // agent-node does not consume, so they deliberately bypass the RFC-024
2283
+ // config-apply pipeline (no doorbell, no ack, no config_revision bump).
2284
+ //
2285
+ // Unlike avatar, these are multi-field and multi-editor, so they carry
2286
+ // their OWN optimistic lock (`attrs_revision`). A stale base revision is
2287
+ // a 409 with the current value, mirroring update_node_config's contract
2288
+ // so the dashboard can reuse its conflict-resolution UX.
2289
+ //
2290
+ // NOT editable here: `alias` (message-routing key) and `node_name`
2291
+ // (addressing key). Renaming those must go through the rename 2PC at
2292
+ // /api/node-rename/* which cascades sessions + api_tokens + SSE cleanup;
2293
+ // a single-table UPDATE would strand the node (see #146).
2294
+ const nodeAttrsMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/attrs$/);
2295
+ if (nodeAttrsMatch && req.method === "PUT") {
2296
+ let attrsBody: any;
2297
+ try {
2298
+ attrsBody = await req.json();
2299
+ } catch {
2300
+ return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
2301
+ }
2302
+ const ref = decodeURIComponent(nodeAttrsMatch[1]);
2303
+ const params: any[] = [ref, ref, ref];
2304
+ let sql = "SELECT node_id, alias, network_id, display_name, team, tags, attrs_revision FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2305
+ sql = addNetworkScope(sql, params, restScope);
2306
+ sql += " ORDER BY updated_at DESC LIMIT 1";
2307
+ const node = db.get<any>(sql, ...params);
2308
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
2309
+
2310
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
2311
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
2312
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2313
+ }
2314
+
2315
+ // Optimistic lock BEFORE any narrowing work, so a losing writer never
2316
+ // has side effects.
2317
+ const currentRev = Number(node.attrs_revision ?? 0);
2318
+ const baseRev = attrsBody?.base_attrs_revision;
2319
+ if (typeof baseRev !== "number" || !Number.isInteger(baseRev) || baseRev < 0) {
2320
+ return withCors(req, Response.json({
2321
+ ok: false, error: "base_attrs_revision required (integer >= 0)",
2322
+ }, { status: 400 }));
2323
+ }
2324
+ if (baseRev !== currentRev) {
2325
+ return withCors(req, Response.json({
2326
+ ok: false,
2327
+ error: "attrs_revision_conflict",
2328
+ current_attrs_revision: currentRev,
2329
+ message: "another editor changed this node's attributes; re-read and retry",
2330
+ }, { status: 409 }));
2331
+ }
2332
+
2333
+ // Scalars reject on wrong type (never coerce); tags drop junk per item.
2334
+ const nextDisplay = "display_name" in attrsBody
2335
+ ? validateScalarAttr(attrsBody.display_name, "display_name")
2336
+ : null;
2337
+ if (nextDisplay && !nextDisplay.ok) {
2338
+ return withCors(req, Response.json({ ok: false, error: "invalid_display_name", reason: nextDisplay.reason }, { status: 400 }));
2339
+ }
2340
+ const nextTeam = "team" in attrsBody ? validateScalarAttr(attrsBody.team, "team") : null;
2341
+ if (nextTeam && !nextTeam.ok) {
2342
+ return withCors(req, Response.json({ ok: false, error: "invalid_team", reason: nextTeam.reason }, { status: 400 }));
2343
+ }
2344
+ const nextTags = "tags" in attrsBody ? narrowTags(attrsBody.tags) : null;
2345
+
2346
+ // Only the supplied fields move; the rest keep their stored value.
2347
+ const sets: string[] = [];
2348
+ const vals: any[] = [];
2349
+ if (nextDisplay) { sets.push(`display_name = ?${vals.length + 1}`); vals.push(nextDisplay.value); }
2350
+ if (nextTeam) { sets.push(`team = ?${vals.length + 1}`); vals.push(nextTeam.value); }
2351
+ if (nextTags) { sets.push(`tags = ?${vals.length + 1}`); vals.push(JSON.stringify(nextTags)); }
2352
+ // A no-op patch still bumps the revision: the caller observed a state
2353
+ // and asserted it, so honouring the CAS keeps client counters in step.
2354
+ sets.push(`attrs_revision = ?${vals.length + 1}`); vals.push(currentRev + 1);
2355
+ sets.push(`updated_at = datetime('now')`);
2356
+ vals.push(node.node_id);
2357
+ // Guard the UPDATE with the same revision so two concurrent writers
2358
+ // that both passed the read above cannot both land.
2359
+ vals.push(currentRev);
2360
+ const res = db.run(
2361
+ `UPDATE nodes SET ${sets.join(", ")} WHERE node_id = ?${vals.length - 1} AND attrs_revision = ?${vals.length}`,
2362
+ vals,
2363
+ );
2364
+ if (!res.changes) {
2365
+ const fresh = db.get<any>("SELECT attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
2366
+ return withCors(req, Response.json({
2367
+ ok: false,
2368
+ error: "attrs_revision_conflict",
2369
+ current_attrs_revision: Number(fresh?.attrs_revision ?? currentRev),
2370
+ message: "another editor changed this node's attributes; re-read and retry",
2371
+ }, { status: 409 }));
2372
+ }
2373
+ logAudit(restAuth?.userId ?? null, restAuth?.username ?? null, "node_attrs_updated", "node", node.node_id,
2374
+ JSON.stringify({ display_name: nextDisplay?.value ?? undefined, team: nextTeam?.value ?? undefined, tags: nextTags ?? undefined }),
2375
+ undefined, nodeNetId ?? undefined);
2376
+ const after = db.get<any>(
2377
+ "SELECT display_name, team, tags, attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
2378
+ return withCors(req, Response.json({
2379
+ ok: true,
2380
+ node_id: node.node_id,
2381
+ alias: node.alias,
2382
+ display_name: after?.display_name ?? null,
2383
+ team: after?.team ?? null,
2384
+ tags: parseStoredTags(after?.tags),
2385
+ attrs_revision: Number(after?.attrs_revision ?? currentRev + 1),
2386
+ }));
2387
+ }
2388
+
2132
2389
  const nodeAvatarMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/avatar$/);
2133
2390
  if (nodeAvatarMatch && req.method === "PUT") {
2134
2391
  let avatarBody: any;
@@ -2315,7 +2572,8 @@ return Bun.serve({
2315
2572
  let sql = `SELECT node_id, node_name, alias, runtime, model,
2316
2573
  config_path, channels, server, hostname,
2317
2574
  network_id, created_at, updated_at,
2318
- config_snapshot, lifecycle_state, avatar_url
2575
+ config_snapshot, lifecycle_state, avatar_url,
2576
+ display_name, team, tags, attrs_revision
2319
2577
  FROM nodes WHERE 1=1`;
2320
2578
  const params: any[] = [];
2321
2579
  sql = addNetworkScope(sql, params, restScope);
@@ -2337,7 +2595,16 @@ return Bun.serve({
2337
2595
  catch { /* malformed snapshot — leave role null */ }
2338
2596
  }
2339
2597
  const { config_snapshot, ...rest } = r;
2340
- return { ...rest, role };
2598
+ // `tags` is stored as JSON text; hand the dashboard a real array so
2599
+ // it can map() without a guard, and normalise legacy/absent values
2600
+ // to [] rather than null. attrs_revision is normalised to a number
2601
+ // so pre-migration rows (NULL) read as 0.
2602
+ return {
2603
+ ...rest,
2604
+ tags: parseStoredTags(r.tags),
2605
+ attrs_revision: Number(r.attrs_revision ?? 0),
2606
+ role,
2607
+ };
2341
2608
  });
2342
2609
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
2343
2610
  }
package/src/tools.ts CHANGED
@@ -1058,7 +1058,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1058
1058
  // ── V2: send_reply (关联 task_id,不触发 think) ──
1059
1059
  server.tool(
1060
1060
  "send_reply",
1061
- "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
1061
+ "Send a reply to a Dashboard/UI-originated task (updates task row, emits new_reply SSE — Dashboard reads it). ⚠ NOT for agent-to-agent replies: agent nodes only log new_reply and do not processInbox on it, so the peer will not see the reply in real time. Use send_task for peer-to-peer replies (RFC-030, Vincent 2026-07-28 全网规则). If the target alias resolves to a live agent node, the response includes a `warning` field pointing to send_task.",
1062
1062
  {
1063
1063
  alias: z.string().min(1).max(200).describe("Target session alias"),
1064
1064
  text: z.string().min(1).max(10000).describe("Reply content"),
@@ -1191,10 +1191,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1191
1191
  // #461 network observer summary — ids + routing only, no reply text.
1192
1192
  pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: alias, status: replyStatus });
1193
1193
 
1194
+ // #498 — When the reply target is a live agent node (not Dashboard/UI),
1195
+ // warn the caller that agent peers only log new_reply and do not
1196
+ // processInbox on it. Points at the correct next action (send_task)
1197
+ // so a follow-up turn corrects course rather than "why didn't they
1198
+ // respond" spelunking. Absent field when target is Dashboard/hub/api
1199
+ // (the correct path for commhub_reply) — silence is affirmation.
1200
+ const targetIsAgent = replyTargetNodeId !== null && alias !== "hub" && alias !== "api";
1201
+ const warning = targetIsAgent
1202
+ ? `Target "${alias}" is an agent node. commhub_reply/send_reply does NOT wake agent peers (they only log new_reply, they do not processInbox on it) — the peer will not see this reply in real time. For agent-to-agent replies, use commhub_send_task(alias="${alias}", task="<your reply>") so the peer wakes via new_task SSE and processes it. (RFC-030, Vincent 2026-07-28 全网规则.)`
1203
+ : undefined;
1204
+
1194
1205
  return {
1195
1206
  content: [{
1196
1207
  type: "text" as const,
1197
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
1208
+ text: JSON.stringify({
1209
+ ok: true,
1210
+ message_id: id,
1211
+ session_status: session?.status ?? "unknown",
1212
+ ...(warning ? { warning } : {}),
1213
+ }),
1198
1214
  }],
1199
1215
  };
1200
1216
  }