@sleep2agi/commhub-server 0.8.3-preview.0 → 0.8.3-preview.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.3-preview.0",
3
+ "version": "0.8.3-preview.2",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
7
7
  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";
8
- import { prepareRename, commitRename, abortRename } from "./rename.js";
8
+ import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
9
9
 
10
10
  const PORT = Number(process.env.PORT) || 9200;
11
11
  const HOST = process.env.HOST || "127.0.0.1";
@@ -918,6 +918,7 @@ Bun.serve({
918
918
  let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
919
919
  staleSql = addNetworkScope(staleSql, staleParams, restScope);
920
920
  db.run(staleSql, staleParams);
921
+ cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
921
922
  const params: any[] = [];
922
923
  let sql = "SELECT * FROM sessions WHERE 1=1";
923
924
  sql = addNetworkScope(sql, params, restScope);
@@ -988,13 +989,38 @@ Bun.serve({
988
989
  last_seen: string | null;
989
990
  }>();
990
991
 
992
+ const preferDisplayIp = (current: string, next: string) => {
993
+ const isWeak = (ip: string) => !ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1";
994
+ if (isWeak(current) && !isWeak(next)) return next;
995
+ return current;
996
+ };
997
+ const hasHostTelemetry = (row: any) =>
998
+ row.cpu_load_1min != null || row.cpu_cores != null || row.mem_avail_gb != null || row.mem_used_gb != null;
999
+
991
1000
  for (const row of db.all<any>(sql, ...params)) {
992
1001
  const hostname = row.hostname || "unknown";
993
1002
  const ip = row.ip || "unknown";
994
- const key = `${hostname}\u0000${ip}`;
1003
+ // Group primarily by hostname. A single host can report both a
1004
+ // routable/container IP and loopback (127.0.0.1); splitting those
1005
+ // into separate cards makes the dashboard show one useful load row
1006
+ // plus one "n/a" duplicate. Unknown hostnames still fall back to IP.
1007
+ const key = hostname !== "unknown" ? `host:${hostname}` : `ip:${ip}`;
995
1008
  const existing = grouped.get(key);
996
1009
  if (existing) {
997
1010
  existing.agent_count += 1;
1011
+ existing.ip = preferDisplayIp(existing.ip, ip);
1012
+ if (parseSqliteTime(row.last_seen) > parseSqliteTime(existing.last_seen)) existing.last_seen = row.last_seen ?? existing.last_seen;
1013
+ if (hasHostTelemetry(row) && (
1014
+ existing.cpu_load_1min == null ||
1015
+ existing.cpu_cores == null ||
1016
+ existing.mem_avail_gb == null ||
1017
+ existing.mem_used_gb == null
1018
+ )) {
1019
+ existing.cpu_load_1min = row.cpu_load_1min ?? existing.cpu_load_1min;
1020
+ existing.cpu_cores = row.cpu_cores ?? existing.cpu_cores;
1021
+ existing.mem_avail_gb = row.mem_avail_gb ?? existing.mem_avail_gb;
1022
+ existing.mem_used_gb = row.mem_used_gb ?? existing.mem_used_gb;
1023
+ }
998
1024
  continue;
999
1025
  }
1000
1026
  grouped.set(key, {
@@ -1163,6 +1189,8 @@ Bun.serve({
1163
1189
  if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
1164
1190
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1165
1191
  }
1192
+ const canonical = resolveCanonicalAlias(taskNetId, body.alias);
1193
+ const targetAlias = canonical.alias;
1166
1194
  const id = crypto.randomUUID();
1167
1195
  const fromSession = body.from || "api";
1168
1196
  const ttlSeconds = (body as any).ttl_seconds || 3600;
@@ -1175,33 +1203,33 @@ Bun.serve({
1175
1203
  db.run(
1176
1204
  `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
1177
1205
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
1178
- [id, body.alias, body.priority, body.task, fromSession, taskNetId]
1206
+ [id, targetAlias, body.priority, body.task, fromSession, taskNetId]
1179
1207
  );
1180
1208
  db.run(
1181
1209
  `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id)
1182
1210
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
1183
- [id, fromSession, body.alias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null]
1211
+ [id, fromSession, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null]
1184
1212
  );
1185
1213
  // Touch session row so the dashboard reflects "task in flight"
1186
1214
  // immediately, without waiting for the agent's report_status to
1187
1215
  // arrive. Updating both `task` and `updated_at` is enough — we
1188
1216
  // leave `status` to the agent (idle → working → idle).
1189
- const touchParams: any[] = [body.task.slice(0, 200), body.alias];
1217
+ const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
1190
1218
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1191
1219
  if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
1192
1220
  db.run(touchSql, touchParams);
1193
1221
  });
1194
1222
  // SSE push: 秒达
1195
- const pendingParams: any[] = [body.alias];
1223
+ const pendingParams: any[] = [targetAlias];
1196
1224
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
1197
1225
  if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
1198
1226
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
1199
- const sessionParams: any[] = [body.alias];
1227
+ const sessionParams: any[] = [targetAlias];
1200
1228
  let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
1201
1229
  if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
1202
1230
  const targetSession = db.get<any>(sessionSql, ...sessionParams);
1203
- if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession }, taskNetId);
1204
- return withCors(req, Response.json({ ok: true, task_id: id, message_id: id }));
1231
+ if (targetSession) pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
1232
+ return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
1205
1233
  }
1206
1234
 
1207
1235
  // ── REST: broadcast ──
package/src/rename.ts CHANGED
@@ -23,11 +23,81 @@ export interface RenameResult {
23
23
  error?: string;
24
24
  }
25
25
 
26
+ export interface CanonicalAlias {
27
+ alias: string;
28
+ renamed: boolean;
29
+ renamed_from?: string;
30
+ chain: string[];
31
+ }
32
+
26
33
  function hasWriteAccess(userId: string, networkId: string): boolean {
27
34
  const role = getUserNetworkRole(userId, networkId);
28
35
  return !!role && role !== "viewer";
29
36
  }
30
37
 
38
+ // Resolve committed alias renames (old -> new), following short chains such
39
+ // as A -> B -> C. This is intentionally server-side canonicalization: stale
40
+ // clients may keep reporting/sending to an old alias after commit, and the hub
41
+ // must not let that recreate orphan session rows (#146/#172).
42
+ export function resolveCanonicalAlias(networkId: string | null | undefined, alias: string): CanonicalAlias {
43
+ if (!networkId || !alias) return { alias, renamed: false, chain: [alias] };
44
+
45
+ const chain = [alias];
46
+ let current = alias;
47
+ const seen = new Set([alias]);
48
+ for (let i = 0; i < 8; i++) {
49
+ const row = db.get<{ new_alias: string }>(
50
+ "SELECT new_alias FROM rename_txn WHERE network_id = ?1 AND old_alias = ?2 AND status = 'committed' ORDER BY committed_at DESC LIMIT 1",
51
+ networkId, current
52
+ );
53
+ if (!row?.new_alias || seen.has(row.new_alias)) break;
54
+ current = row.new_alias;
55
+ chain.push(current);
56
+ seen.add(current);
57
+ }
58
+
59
+ return {
60
+ alias: current,
61
+ renamed: current !== alias,
62
+ renamed_from: current !== alias ? alias : undefined,
63
+ chain,
64
+ };
65
+ }
66
+
67
+ export function canonicalAliasExists(networkId: string, alias: string, excludingResumeId?: string | null): boolean {
68
+ const params: any[] = [networkId, alias];
69
+ let sql = "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2";
70
+ if (excludingResumeId) {
71
+ sql += " AND resume_id != ?3";
72
+ params.push(excludingResumeId);
73
+ }
74
+ return !!db.get<any>(sql, ...params);
75
+ }
76
+
77
+ export function cleanupRenamedAliasSession(networkId: string | null | undefined, oldAlias: string, newAlias: string): void {
78
+ if (!networkId || oldAlias === newAlias) return;
79
+ const existsNew = db.get<any>(
80
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2",
81
+ networkId, newAlias
82
+ );
83
+ if (!existsNew) return;
84
+ db.run("DELETE FROM sessions WHERE network_id = ?1 AND alias = ?2", [networkId, oldAlias]);
85
+ }
86
+
87
+ export function cleanupCommittedRenameSessions(networkIds: string[] | null = null): void {
88
+ const params: any[] = [];
89
+ let sql = "SELECT network_id, old_alias, new_alias FROM rename_txn WHERE status = 'committed'";
90
+ if (networkIds) {
91
+ if (networkIds.length === 0) return;
92
+ const placeholders = networkIds.map((_, i) => `?${i + 1}`).join(", ");
93
+ sql += ` AND network_id IN (${placeholders})`;
94
+ params.push(...networkIds);
95
+ }
96
+ for (const row of db.all<{ network_id: string; old_alias: string; new_alias: string }>(sql, ...params)) {
97
+ cleanupRenamedAliasSession(row.network_id, row.old_alias, row.new_alias);
98
+ }
99
+ }
100
+
31
101
  // PHASE 1 P3 — create a prepared rename_txn row reserving new_alias.
32
102
  // CAS guard (RFC §4 risk #3 TOCTOU): new_alias must be free both in the
33
103
  // sessions registry AND among in-flight prepared rename_txn rows.
@@ -80,6 +150,7 @@ export function commitRename(userId: string, txnId: string): RenameResult {
80
150
  db.run(
81
151
  "UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
82
152
  [txn.new_alias, txn.network_id, txn.old_alias]);
153
+ cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
83
154
  db.run(
84
155
  "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
85
156
  [txn.new_alias, txn.network_id, txn.old_alias]);
package/src/tools.ts CHANGED
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
3
3
  import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
4
4
  import { pushEvent } from "./push.js";
5
5
  import { getUserNetworkRole } from "./auth.js";
6
+ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
6
7
 
7
8
  function ts(): string {
8
9
  return new Date().toTimeString().slice(0, 8);
@@ -149,7 +150,36 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
149
150
  if (!canWrite(effectiveNetId)) {
150
151
  return writeDeniedReply(effectiveNetId);
151
152
  }
152
- console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
153
+ const canonical = resolveCanonicalAlias(sessionNetId, alias);
154
+ let effectiveAlias = canonical.alias;
155
+ if (canonical.renamed) {
156
+ // A stale process may keep heartbeating with the old alias after a
157
+ // committed rename. If the new alias is already active, ignore the
158
+ // stale report and clean the old row instead of letting it recreate
159
+ // a red/orphan dashboard node (#146/#172). If not active yet, rewrite
160
+ // the incoming report to the canonical alias so startup can converge.
161
+ if (canonicalAliasExists(sessionNetId, effectiveAlias, resume_id)) {
162
+ cleanupRenamedAliasSession(sessionNetId, alias, effectiveAlias);
163
+ const pendingParams: any[] = [effectiveAlias];
164
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
165
+ pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
166
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
167
+ return {
168
+ content: [{
169
+ type: "text" as const,
170
+ text: JSON.stringify({
171
+ ok: true,
172
+ resume_id,
173
+ alias: effectiveAlias,
174
+ renamed_from: alias,
175
+ ignored_stale_alias: true,
176
+ inbox_count: pending?.cnt ?? 0,
177
+ }),
178
+ }],
179
+ };
180
+ }
181
+ }
182
+ console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
153
183
  const trimmedOutput = output?.slice(0, 4000);
154
184
  const hostHostname = host?.hostname || hn || null;
155
185
  const hostIp = host?.ip || clientIP || null;
@@ -190,7 +220,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
190
220
 
191
221
  db.transaction(() => {
192
222
  // Only delete same-alias sessions within the same network
193
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, sessionNetId]);
223
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
194
224
  db.run(
195
225
  `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, last_seen_at, updated_at)
196
226
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, datetime('now'), datetime('now'))
@@ -219,19 +249,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
219
249
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
220
250
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
221
251
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
222
- [resume_id, alias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
252
+ [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
223
253
  );
224
254
  if (host || proc) {
225
255
  db.run(
226
256
  `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
227
257
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'))`,
228
- [uuidv4(), sessionNetId, resume_id, alias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
258
+ [uuidv4(), sessionNetId, resume_id, effectiveAlias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
229
259
  );
230
260
  }
231
261
  });
232
- pushEvent(alias, {
262
+ pushEvent(effectiveAlias, {
233
263
  type: "status_update",
234
- alias,
264
+ alias: effectiveAlias,
265
+ ...(canonical.renamed ? { renamed_from: alias } : {}),
235
266
  status,
236
267
  progress: progress ?? null,
237
268
  host: statusHostTelemetry,
@@ -241,19 +272,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
241
272
  // V2: sync tasks table — report_status(working) → tasks.running
242
273
  if (status === "working" && task) {
243
274
  try {
244
- const runParams: any[] = [alias, task];
275
+ const runParams: any[] = [effectiveAlias, task];
245
276
  let runSql = `UPDATE tasks SET status = 'running', started_at = datetime('now')
246
277
  WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`;
247
278
  runSql = addScope(runSql, runParams, effectiveNetId);
248
279
  const runResult = db.run(runSql, runParams);
249
280
  if (runResult.changes > 0) {
250
281
  // Find task_id for logging
251
- const findParams: any[] = [alias, task];
282
+ const findParams: any[] = [effectiveAlias, task];
252
283
  let findSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running'";
253
284
  findSql = addScope(findSql, findParams, effectiveNetId);
254
285
  findSql += " ORDER BY started_at DESC LIMIT 1";
255
286
  const t = db.get<{ task_id: string }>(findSql, ...findParams);
256
- if (t) logTaskEvent(t.task_id, null, "running", alias);
287
+ if (t) logTaskEvent(t.task_id, null, "running", effectiveAlias);
257
288
  }
258
289
  } catch {}
259
290
  }
@@ -277,13 +308,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
277
308
  hostname = COALESCE(?9, nodes.hostname),
278
309
  network_id = COALESCE(?10, nodes.network_id),
279
310
  updated_at = datetime('now')`,
280
- [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
311
+ [node_id, nn || effectiveAlias, effectiveAlias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
281
312
  );
282
313
  } catch {}
283
314
  }
284
315
 
285
316
  // inbox uses alias for routing
286
- const inboxParams: any[] = [alias];
317
+ const inboxParams: any[] = [effectiveAlias];
287
318
  let inboxSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
288
319
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
289
320
  const row = db.get<{ cnt: number }>(inboxSql, ...inboxParams);
@@ -295,7 +326,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
295
326
  text: JSON.stringify({
296
327
  ok: true,
297
328
  resume_id,
298
- alias,
329
+ alias: effectiveAlias,
330
+ ...(canonical.renamed ? { renamed_from: alias } : {}),
299
331
  inbox_count: row?.cnt ?? 0,
300
332
  }),
301
333
  },
@@ -587,7 +619,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
587
619
  }
588
620
  }
589
621
 
590
- console.log(`[${ts()}] ${from_session} send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
622
+ const canonical = resolveCanonicalAlias(effectiveNetId, alias);
623
+ const targetAlias = canonical.alias;
624
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
591
625
  const id = uuidv4();
592
626
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
593
627
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
@@ -597,21 +631,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
597
631
  db.run(
598
632
  `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
599
633
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
600
- [id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
634
+ [id, targetAlias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
601
635
  );
602
636
  db.run(
603
637
  `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id)
604
638
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
605
- [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId]
639
+ [id, from_session, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId]
606
640
  );
607
- const touchParams: any[] = [task.slice(0, 200), alias];
641
+ const touchParams: any[] = [task.slice(0, 200), targetAlias];
608
642
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
609
643
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
610
644
  db.run(touchSql, touchParams);
611
645
  });
612
- logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${alias} (parent=${parentTaskId.slice(0,8)})` : `→ ${alias}`);
646
+ logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
613
647
 
614
- const session = scopedSessionStatus(alias, effectiveNetId);
648
+ const session = scopedSessionStatus(targetAlias, effectiveNetId);
615
649
 
616
650
  // SSE push by alias.
617
651
  // The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
@@ -620,11 +654,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
620
654
  // network_id=null but the sender supplied an explicit network_id (the
621
655
  // exact mismatch hit by Dashboard tasks). Push unconditionally; the
622
656
  // subscriber's own auth (ntok_) constrains who can listen.
623
- const pendingParams: any[] = [alias];
657
+ const pendingParams: any[] = [targetAlias];
624
658
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
625
659
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
626
660
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
627
- pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session }, effectiveNetId);
661
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
628
662
 
629
663
  return {
630
664
  content: [
@@ -633,6 +667,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
633
667
  text: JSON.stringify({
634
668
  ok: true,
635
669
  message_id: id,
670
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
636
671
  session_status: session?.status ?? "unknown",
637
672
  }),
638
673
  },