@sleep2agi/commhub-server 0.8.3-preview.1 → 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.1",
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);
@@ -1188,6 +1189,8 @@ Bun.serve({
1188
1189
  if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
1189
1190
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1190
1191
  }
1192
+ const canonical = resolveCanonicalAlias(taskNetId, body.alias);
1193
+ const targetAlias = canonical.alias;
1191
1194
  const id = crypto.randomUUID();
1192
1195
  const fromSession = body.from || "api";
1193
1196
  const ttlSeconds = (body as any).ttl_seconds || 3600;
@@ -1200,33 +1203,33 @@ Bun.serve({
1200
1203
  db.run(
1201
1204
  `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
1202
1205
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
1203
- [id, body.alias, body.priority, body.task, fromSession, taskNetId]
1206
+ [id, targetAlias, body.priority, body.task, fromSession, taskNetId]
1204
1207
  );
1205
1208
  db.run(
1206
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)
1207
1210
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
1208
- [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]
1209
1212
  );
1210
1213
  // Touch session row so the dashboard reflects "task in flight"
1211
1214
  // immediately, without waiting for the agent's report_status to
1212
1215
  // arrive. Updating both `task` and `updated_at` is enough — we
1213
1216
  // leave `status` to the agent (idle → working → idle).
1214
- const touchParams: any[] = [body.task.slice(0, 200), body.alias];
1217
+ const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
1215
1218
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1216
1219
  if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
1217
1220
  db.run(touchSql, touchParams);
1218
1221
  });
1219
1222
  // SSE push: 秒达
1220
- const pendingParams: any[] = [body.alias];
1223
+ const pendingParams: any[] = [targetAlias];
1221
1224
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
1222
1225
  if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
1223
1226
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
1224
- const sessionParams: any[] = [body.alias];
1227
+ const sessionParams: any[] = [targetAlias];
1225
1228
  let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
1226
1229
  if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
1227
1230
  const targetSession = db.get<any>(sessionSql, ...sessionParams);
1228
- if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession }, taskNetId);
1229
- 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 } : {}) }));
1230
1233
  }
1231
1234
 
1232
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
  },