@sleep2agi/commhub-server 0.8.3-preview.1 → 0.8.3

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",
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/db.ts CHANGED
@@ -129,6 +129,7 @@ for (const col of [
129
129
  { name: "requires_response", def: "TEXT DEFAULT 'reply'" },
130
130
  { name: "expires_at", def: "TEXT" },
131
131
  { name: "scope", def: "TEXT DEFAULT 'single'" },
132
+ { name: "meta_json", def: "TEXT" },
132
133
  ]) {
133
134
  try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
134
135
  }
@@ -489,6 +490,7 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions
489
490
  // admin to see the answer even if 指挥室's own session has died. The hub forwards
490
491
  // the reply up the chain via parent_task_id.
491
492
  try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
493
+ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
492
494
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
493
495
 
494
496
  // Helpers
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";
@@ -51,6 +51,11 @@ console.info = (...args: any[]) => { pushLog("info", args); _origConsole.info(..
51
51
  console.warn = (...args: any[]) => { pushLog("warn", args); _origConsole.warn(...args); };
52
52
  console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error(...args); };
53
53
 
54
+ function normalizeMetaJson(meta: unknown): string | null {
55
+ if (!meta || typeof meta !== "object") return null;
56
+ try { return JSON.stringify(meta); } catch { return null; }
57
+ }
58
+
54
59
  // ── Rate limiter (in-memory, per IP) ──
55
60
  const rateLimits = new Map<string, { count: number; resetAt: number }>();
56
61
  function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
@@ -343,6 +348,8 @@ const TaskSchema = z.object({
343
348
  from: z.string().max(200).optional(),
344
349
  network_id: z.string().max(200).optional(),
345
350
  parent_task_id: z.string().max(200).optional(),
351
+ ttl_seconds: z.number().min(1).max(86400).optional(),
352
+ meta: z.any().optional(),
346
353
  });
347
354
 
348
355
  const BroadcastSchema = z.object({
@@ -918,6 +925,7 @@ Bun.serve({
918
925
  let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
919
926
  staleSql = addNetworkScope(staleSql, staleParams, restScope);
920
927
  db.run(staleSql, staleParams);
928
+ cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
921
929
  const params: any[] = [];
922
930
  let sql = "SELECT * FROM sessions WHERE 1=1";
923
931
  sql = addNetworkScope(sql, params, restScope);
@@ -1188,9 +1196,12 @@ Bun.serve({
1188
1196
  if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
1189
1197
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1190
1198
  }
1199
+ const canonical = resolveCanonicalAlias(taskNetId, body.alias);
1200
+ const targetAlias = canonical.alias;
1191
1201
  const id = crypto.randomUUID();
1192
1202
  const fromSession = body.from || "api";
1193
1203
  const ttlSeconds = (body as any).ttl_seconds || 3600;
1204
+ const metaJson = normalizeMetaJson((body as any).meta);
1194
1205
  // Mirror send_task MCP: write inbox + tasks rows in a single
1195
1206
  // transaction so the dispatch is visible to dashboard's Tasks page
1196
1207
  // and the parent_task_id lineage chain. Previously this endpoint
@@ -1198,35 +1209,35 @@ Bun.serve({
1198
1209
  // dispatched via REST (anet demo, dashboard Dispatch button, etc.).
1199
1210
  db.transaction(() => {
1200
1211
  db.run(
1201
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
1202
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
1203
- [id, body.alias, body.priority, body.task, fromSession, taskNetId]
1212
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id, meta_json)
1213
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6, ?7)`,
1214
+ [id, targetAlias, body.priority, body.task, fromSession, taskNetId, metaJson]
1204
1215
  );
1205
1216
  db.run(
1206
- `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
- 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]
1217
+ `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, meta_json)
1218
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
1219
+ [id, fromSession, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
1209
1220
  );
1210
1221
  // Touch session row so the dashboard reflects "task in flight"
1211
1222
  // immediately, without waiting for the agent's report_status to
1212
1223
  // arrive. Updating both `task` and `updated_at` is enough — we
1213
1224
  // leave `status` to the agent (idle → working → idle).
1214
- const touchParams: any[] = [body.task.slice(0, 200), body.alias];
1225
+ const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
1215
1226
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1216
1227
  if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
1217
1228
  db.run(touchSql, touchParams);
1218
1229
  });
1219
1230
  // SSE push: 秒达
1220
- const pendingParams: any[] = [body.alias];
1231
+ const pendingParams: any[] = [targetAlias];
1221
1232
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
1222
1233
  if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
1223
1234
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
1224
- const sessionParams: any[] = [body.alias];
1235
+ const sessionParams: any[] = [targetAlias];
1225
1236
  let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
1226
1237
  if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
1227
1238
  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 }));
1239
+ if (targetSession) pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
1240
+ return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
1230
1241
  }
1231
1242
 
1232
1243
  // ── 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,11 +3,22 @@ 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);
9
10
  }
10
11
 
12
+ function parseMetaJson(value: unknown): unknown | null {
13
+ if (!value || typeof value !== "string") return null;
14
+ try { return JSON.parse(value); } catch { return null; }
15
+ }
16
+
17
+ function normalizeMetaJson(meta: unknown): string | null {
18
+ if (!meta || typeof meta !== "object") return null;
19
+ try { return JSON.stringify(meta); } catch { return null; }
20
+ }
21
+
11
22
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false) {
12
23
  // Default from_session for outbound tools — extracted from the calling
13
24
  // token's binding (ntok_ → node alias, utok_ → username). Without this,
@@ -149,7 +160,36 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
149
160
  if (!canWrite(effectiveNetId)) {
150
161
  return writeDeniedReply(effectiveNetId);
151
162
  }
152
- console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
163
+ const canonical = resolveCanonicalAlias(sessionNetId, alias);
164
+ let effectiveAlias = canonical.alias;
165
+ if (canonical.renamed) {
166
+ // A stale process may keep heartbeating with the old alias after a
167
+ // committed rename. If the new alias is already active, ignore the
168
+ // stale report and clean the old row instead of letting it recreate
169
+ // a red/orphan dashboard node (#146/#172). If not active yet, rewrite
170
+ // the incoming report to the canonical alias so startup can converge.
171
+ if (canonicalAliasExists(sessionNetId, effectiveAlias, resume_id)) {
172
+ cleanupRenamedAliasSession(sessionNetId, alias, effectiveAlias);
173
+ const pendingParams: any[] = [effectiveAlias];
174
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
175
+ pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
176
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
177
+ return {
178
+ content: [{
179
+ type: "text" as const,
180
+ text: JSON.stringify({
181
+ ok: true,
182
+ resume_id,
183
+ alias: effectiveAlias,
184
+ renamed_from: alias,
185
+ ignored_stale_alias: true,
186
+ inbox_count: pending?.cnt ?? 0,
187
+ }),
188
+ }],
189
+ };
190
+ }
191
+ }
192
+ 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
193
  const trimmedOutput = output?.slice(0, 4000);
154
194
  const hostHostname = host?.hostname || hn || null;
155
195
  const hostIp = host?.ip || clientIP || null;
@@ -190,7 +230,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
190
230
 
191
231
  db.transaction(() => {
192
232
  // 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]);
233
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
194
234
  db.run(
195
235
  `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
236
  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 +259,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
219
259
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
220
260
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
221
261
  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]
262
+ [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
263
  );
224
264
  if (host || proc) {
225
265
  db.run(
226
266
  `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
267
  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]
268
+ [uuidv4(), sessionNetId, resume_id, effectiveAlias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
229
269
  );
230
270
  }
231
271
  });
232
- pushEvent(alias, {
272
+ pushEvent(effectiveAlias, {
233
273
  type: "status_update",
234
- alias,
274
+ alias: effectiveAlias,
275
+ ...(canonical.renamed ? { renamed_from: alias } : {}),
235
276
  status,
236
277
  progress: progress ?? null,
237
278
  host: statusHostTelemetry,
@@ -241,19 +282,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
241
282
  // V2: sync tasks table — report_status(working) → tasks.running
242
283
  if (status === "working" && task) {
243
284
  try {
244
- const runParams: any[] = [alias, task];
285
+ const runParams: any[] = [effectiveAlias, task];
245
286
  let runSql = `UPDATE tasks SET status = 'running', started_at = datetime('now')
246
287
  WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`;
247
288
  runSql = addScope(runSql, runParams, effectiveNetId);
248
289
  const runResult = db.run(runSql, runParams);
249
290
  if (runResult.changes > 0) {
250
291
  // Find task_id for logging
251
- const findParams: any[] = [alias, task];
292
+ const findParams: any[] = [effectiveAlias, task];
252
293
  let findSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running'";
253
294
  findSql = addScope(findSql, findParams, effectiveNetId);
254
295
  findSql += " ORDER BY started_at DESC LIMIT 1";
255
296
  const t = db.get<{ task_id: string }>(findSql, ...findParams);
256
- if (t) logTaskEvent(t.task_id, null, "running", alias);
297
+ if (t) logTaskEvent(t.task_id, null, "running", effectiveAlias);
257
298
  }
258
299
  } catch {}
259
300
  }
@@ -277,13 +318,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
277
318
  hostname = COALESCE(?9, nodes.hostname),
278
319
  network_id = COALESCE(?10, nodes.network_id),
279
320
  updated_at = datetime('now')`,
280
- [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
321
+ [node_id, nn || effectiveAlias, effectiveAlias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
281
322
  );
282
323
  } catch {}
283
324
  }
284
325
 
285
326
  // inbox uses alias for routing
286
- const inboxParams: any[] = [alias];
327
+ const inboxParams: any[] = [effectiveAlias];
287
328
  let inboxSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
288
329
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
289
330
  const row = db.get<{ cnt: number }>(inboxSql, ...inboxParams);
@@ -295,7 +336,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
295
336
  text: JSON.stringify({
296
337
  ok: true,
297
338
  resume_id,
298
- alias,
339
+ alias: effectiveAlias,
340
+ ...(canonical.renamed ? { renamed_from: alias } : {}),
299
341
  inbox_count: row?.cnt ?? 0,
300
342
  }),
301
343
  },
@@ -407,13 +449,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
407
449
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
408
450
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
409
451
  const rowsParams: any[] = [alias];
410
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
452
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
411
453
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
412
454
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
413
455
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
414
456
  LIMIT ?${rowsParams.length + 1}`;
415
457
  rowsParams.push(limit);
416
- const rows = db.all(rowsSql, ...rowsParams);
458
+ const rows = db.all(rowsSql, ...rowsParams).map((row: any) => ({
459
+ ...row,
460
+ meta: parseMetaJson(row.meta_json),
461
+ }));
417
462
 
418
463
  return {
419
464
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
@@ -553,9 +598,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
553
598
  ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
554
599
  network_id: z.string().max(200).optional().describe("Network scope"),
555
600
  parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. When the child task replies the hub will auto-chain the answer to the parent task's originator, so the user sees the final result even if the intermediate session ends."),
601
+ meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
556
602
  },
557
- async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn }) => { const from_session = defaultFrom(_fromIn);
603
+ async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta }) => { const from_session = defaultFrom(_fromIn);
558
604
  const effectiveNetId = getNetworkId(netId);
605
+ const metaJson = normalizeMetaJson(meta);
559
606
  // Resolve parent_task_id: explicit > inferred (caller's most recent
560
607
  // delivered/started inbox task that's still open). Inference is the
561
608
  // safety net for when the LLM forgets to pass parent_task_id.
@@ -587,7 +634,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
587
634
  }
588
635
  }
589
636
 
590
- console.log(`[${ts()}] ${from_session} send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
637
+ const canonical = resolveCanonicalAlias(effectiveNetId, alias);
638
+ const targetAlias = canonical.alias;
639
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
591
640
  const id = uuidv4();
592
641
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
593
642
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
@@ -595,23 +644,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
595
644
  // 报告冲突)。
596
645
  db.transaction(() => {
597
646
  db.run(
598
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
599
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
600
- [id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
647
+ `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id, meta_json)
648
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7, ?8)`,
649
+ [id, targetAlias, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
601
650
  );
602
651
  db.run(
603
- `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
- 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]
652
+ `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, meta_json)
653
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
654
+ [id, from_session, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
606
655
  );
607
- const touchParams: any[] = [task.slice(0, 200), alias];
656
+ const touchParams: any[] = [task.slice(0, 200), targetAlias];
608
657
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
609
658
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
610
659
  db.run(touchSql, touchParams);
611
660
  });
612
- logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${alias} (parent=${parentTaskId.slice(0,8)})` : `→ ${alias}`);
661
+ logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
613
662
 
614
- const session = scopedSessionStatus(alias, effectiveNetId);
663
+ const session = scopedSessionStatus(targetAlias, effectiveNetId);
615
664
 
616
665
  // SSE push by alias.
617
666
  // The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
@@ -620,11 +669,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
620
669
  // network_id=null but the sender supplied an explicit network_id (the
621
670
  // exact mismatch hit by Dashboard tasks). Push unconditionally; the
622
671
  // subscriber's own auth (ntok_) constrains who can listen.
623
- const pendingParams: any[] = [alias];
672
+ const pendingParams: any[] = [targetAlias];
624
673
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
625
674
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
626
675
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
627
- pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session }, effectiveNetId);
676
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
628
677
 
629
678
  return {
630
679
  content: [
@@ -633,6 +682,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
633
682
  text: JSON.stringify({
634
683
  ok: true,
635
684
  message_id: id,
685
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
636
686
  session_status: session?.status ?? "unknown",
637
687
  }),
638
688
  },