ec-web 0.0.3 → 0.0.5

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/dist/server.js CHANGED
@@ -66,10 +66,26 @@ const ECWEB_VERSION = readEcwebVersion();
66
66
  const MIME = {
67
67
  '.html': 'text/html; charset=utf-8',
68
68
  '.js': 'text/javascript; charset=utf-8',
69
+ '.mjs': 'text/javascript; charset=utf-8',
69
70
  '.css': 'text/css; charset=utf-8',
70
71
  '.json': 'application/json; charset=utf-8',
72
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
71
73
  '.svg': 'image/svg+xml',
74
+ '.png': 'image/png',
75
+ '.jpg': 'image/jpeg',
76
+ '.jpeg': 'image/jpeg',
77
+ '.gif': 'image/gif',
78
+ '.webp': 'image/webp',
79
+ '.avif': 'image/avif',
80
+ '.ico': 'image/x-icon',
81
+ '.woff': 'font/woff',
82
+ '.woff2': 'font/woff2',
83
+ '.ttf': 'font/ttf',
84
+ '.otf': 'font/otf',
85
+ '.wasm': 'application/wasm',
72
86
  };
87
+ const HTML_CACHE_CONTROL = 'no-cache';
88
+ const STATIC_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
73
89
  function tokenStorePath() {
74
90
  const dir = resolvePaths().instanceDir;
75
91
  const current = path.join(dir, 'ecweb-tokens.json');
@@ -141,7 +157,32 @@ function injectBaseHref(html, prefix) {
141
157
  return html.replace(/<base\b[^>]*>/i, tag);
142
158
  return html.replace(/<head[^>]*>/i, (m) => `${m}\n ${tag}`);
143
159
  }
144
- function serveStatic(req, res) {
160
+ function createEtag(content) {
161
+ return `"${crypto.createHash('sha256').update(content).digest('hex')}"`;
162
+ }
163
+ function matchesIfNoneMatch(req, etag) {
164
+ const header = req.headers['if-none-match'];
165
+ const value = Array.isArray(header) ? header.join(',') : header;
166
+ if (!value)
167
+ return false;
168
+ // GET uses weak comparison, so a client may send the validator with W/.
169
+ return value.split(',').some((candidate) => {
170
+ const normalized = candidate.trim().replace(/^W\//, '');
171
+ return normalized === '*' || normalized === etag;
172
+ });
173
+ }
174
+ function respondWithValidator(req, res, body, contentType, cacheControl, extraHeaders = {}) {
175
+ const etag = createEtag(body);
176
+ const headers = { 'Content-Type': contentType, 'Cache-Control': cacheControl, ETag: etag, ...extraHeaders };
177
+ if (matchesIfNoneMatch(req, etag)) {
178
+ res.writeHead(304, headers);
179
+ res.end();
180
+ return;
181
+ }
182
+ res.writeHead(200, headers);
183
+ res.end(body);
184
+ }
185
+ export function serveStatic(req, res) {
145
186
  let urlPath = (req.url || '/').split('?')[0];
146
187
  if (urlPath === '/')
147
188
  urlPath = '/index.html';
@@ -161,12 +202,13 @@ function serveStatic(req, res) {
161
202
  if (ext === '.html') {
162
203
  const prefix = forwardedPrefix(req);
163
204
  const html = injectBaseHref(data.toString('utf-8'), prefix);
164
- res.writeHead(200, { 'Content-Type': MIME[ext] });
165
- res.end(html);
205
+ // The proxy prefix changes the representation, so shared caches must key on it.
206
+ respondWithValidator(req, res, html, MIME[ext], HTML_CACHE_CONTROL, { Vary: 'X-Forwarded-Prefix' });
166
207
  return;
167
208
  }
168
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
169
- res.end(data);
209
+ // Static files have stable ETags, including fonts, images, manifests, and vendored libraries.
210
+ // Their fixed URLs must revalidate so deployments never leave clients on an old asset.
211
+ respondWithValidator(req, res, data, MIME[ext] || 'application/octet-stream', STATIC_CACHE_CONTROL);
170
212
  });
171
213
  }
172
214
  // ── Helpers ──
@@ -446,6 +488,7 @@ function handleConnection(ws, req, log) {
446
488
  const ip = clientIp(req);
447
489
  let unsubscribe = null;
448
490
  let currentView = null;
491
+ let subscriptionGeneration = 0;
449
492
  log(`◆ WS 连接 from ${ip}`);
450
493
  const send = (obj) => {
451
494
  if (ws.readyState === ws.OPEN)
@@ -455,6 +498,7 @@ function handleConnection(ws, req, log) {
455
498
  catch { }
456
499
  };
457
500
  const switchSubscription = async (view, params) => {
501
+ const generation = ++subscriptionGeneration;
458
502
  if (unsubscribe) {
459
503
  unsubscribe();
460
504
  unsubscribe = null;
@@ -468,16 +512,28 @@ function handleConnection(ws, req, log) {
468
512
  const sourceParams = { ...params, __menuAuth: daemonOwnerMenuAuth() };
469
513
  // System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
470
514
  const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
515
+ let initialSnapshot;
471
516
  try {
472
- send({ type: 'snapshot', view, data: decorate(await source.snapshot(sourceParams)) });
517
+ initialSnapshot = await source.snapshot(sourceParams);
518
+ if (generation !== subscriptionGeneration)
519
+ return;
520
+ send({ type: 'snapshot', view, data: decorate(initialSnapshot) });
473
521
  }
474
522
  catch (e) {
523
+ if (generation !== subscriptionGeneration)
524
+ return;
475
525
  send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
476
526
  }
477
- unsubscribe = source.subscribe(sourceParams, (data) => {
478
- if (currentView === view)
527
+ const nextUnsubscribe = source.subscribe(sourceParams, (data) => {
528
+ if (generation === subscriptionGeneration && currentView === view) {
479
529
  send({ type: 'delta', view, data: decorate(data) });
530
+ }
480
531
  });
532
+ if (generation !== subscriptionGeneration) {
533
+ nextUnsubscribe();
534
+ return;
535
+ }
536
+ unsubscribe = nextUnsubscribe;
481
537
  };
482
538
  ws.on('message', async (raw) => {
483
539
  let msg;
@@ -563,10 +619,15 @@ function handleConnection(ws, req, log) {
563
619
  ws.ping();
564
620
  }, 25000);
565
621
  ws.on('pong', () => { alive = true; });
566
- ws.on('close', () => { clearInterval(heartbeat); if (unsubscribe) {
567
- unsubscribe();
568
- unsubscribe = null;
569
- } log(`◇ WS 断开 from ${ip}`); });
622
+ ws.on('close', () => {
623
+ subscriptionGeneration += 1;
624
+ clearInterval(heartbeat);
625
+ if (unsubscribe) {
626
+ unsubscribe();
627
+ unsubscribe = null;
628
+ }
629
+ log(`◇ WS 断开 from ${ip}`);
630
+ });
570
631
  ws.on('error', () => { });
571
632
  }
572
633
  // ── Port binding ──
@@ -8,11 +8,44 @@
8
8
  * - fine : 2s 桶 × 60 ≈ 近 2 分钟
9
9
  * - mid : 10s 桶 × 60 ≈ 近 10 分钟
10
10
  * - coarse : 60s 桶 × 60 ≈ 近 1 小时
11
- * 每个桶内对 CPU/内存做均值聚合,避免长周期下点数爆炸。
11
+ * 每个桶内对 CPU/内存做均值聚合,关联进程先按类型和名称合并,
12
+ * 避免长周期下点数爆炸,也不让 PID 变化拆散同一条曲线。
12
13
  */
13
14
  import fs from 'node:fs';
14
15
  import { resolvePaths } from '../paths.js';
15
16
  import { ipcQuery } from '../ipc-client.js';
17
+ function processGroupKey(process) {
18
+ return `${process.kind}\u0000${process.name}`;
19
+ }
20
+ /** 同一类、同一可执行名的多个实例在一个采样点中汇总。 */
21
+ export function mergeProcessesByIdentity(processes) {
22
+ const merged = new Map();
23
+ for (const process of processes) {
24
+ const name = String(process.name || 'process');
25
+ const kind = String(process.kind || 'child');
26
+ const key = processGroupKey({ name, kind });
27
+ const current = merged.get(key);
28
+ if (current) {
29
+ current.cpuPercent += Number(process.cpuPercent) || 0;
30
+ current.rss += Number(process.rss) || 0;
31
+ }
32
+ else {
33
+ merged.set(key, {
34
+ name,
35
+ kind,
36
+ cpuPercent: Number(process.cpuPercent) || 0,
37
+ rss: Number(process.rss) || 0,
38
+ });
39
+ }
40
+ }
41
+ return [...merged.values()].sort((left, right) => processGroupKey(left).localeCompare(processGroupKey(right)));
42
+ }
43
+ /** 将进程的单核 CPU 百分比换算为整机全部逻辑 CPU 的容量占比。 */
44
+ export function normalizeProcessCpuPercent(cpuPercent, cpuCount) {
45
+ const count = Number(cpuCount);
46
+ const divisor = Number.isFinite(count) && count > 0 ? count : 1;
47
+ return (Number(cpuPercent) || 0) / divisor;
48
+ }
16
49
  // 模块级缓冲区——所有订阅者共享同一份累积时序(单一事实源)。
17
50
  const resolutions = {
18
51
  fine: { bucketMs: 2_000, cap: 60, buf: [] },
@@ -29,10 +62,12 @@ function ingest(res, sample) {
29
62
  last.sysCpu = last.sysCpu + (sample.sysCpu - last.sysCpu) / n;
30
63
  last.procRss = last.procRss + (sample.procRss - last.procRss) / n;
31
64
  last.sysMemUsed = last.sysMemUsed + (sample.sysMemUsed - last.sysMemUsed) / n;
65
+ last.diskReadBps = last.diskReadBps + (sample.diskReadBps - last.diskReadBps) / n;
66
+ last.diskWriteBps = last.diskWriteBps + (sample.diskWriteBps - last.diskWriteBps) / n;
32
67
  last.netRxBps = last.netRxBps + (sample.netRxBps - last.netRxBps) / n;
33
68
  last.netTxBps = last.netTxBps + (sample.netTxBps - last.netTxBps) / n;
34
69
  for (const process of sample.processes) {
35
- const current = last.processes.find(item => item.pid === process.pid);
70
+ const current = last.processes.find(item => processGroupKey(item) === processGroupKey(process));
36
71
  if (current) {
37
72
  const processSamples = current._n + 1;
38
73
  current.cpuPercent += (process.cpuPercent - current.cpuPercent) / processSamples;
@@ -61,10 +96,11 @@ function exportBuf(res) {
61
96
  sysCpu: round1(b.sysCpu),
62
97
  procRss: Math.round(b.procRss),
63
98
  sysMemUsed: Math.round(b.sysMemUsed),
99
+ diskReadBps: Math.round(b.diskReadBps),
100
+ diskWriteBps: Math.round(b.diskWriteBps),
64
101
  netRxBps: Math.round(b.netRxBps),
65
102
  netTxBps: Math.round(b.netTxBps),
66
103
  processes: b.processes.map(process => ({
67
- pid: process.pid,
68
104
  name: process.name,
69
105
  kind: process.kind,
70
106
  cpuPercent: round1(process.cpuPercent),
@@ -73,6 +109,49 @@ function exportBuf(res) {
73
109
  }));
74
110
  }
75
111
  function round1(n) { return Math.round(n * 10) / 10; }
112
+ const DISK_SECTOR_BYTES = 512;
113
+ let previousDiskSample = null;
114
+ let latestDiskRate = { readBps: 0, writeBps: 0 };
115
+ /** 解析 Linux diskstats,只累计整盘,避免分区和虚拟映射重复计数。 */
116
+ export function parseLinuxDiskStats(input, isPartition = name => fs.existsSync(`/sys/class/block/${name}/partition`)) {
117
+ let readBytes = 0;
118
+ let writeBytes = 0;
119
+ for (const line of input.split('\n')) {
120
+ const fields = line.trim().split(/\s+/);
121
+ if (fields.length < 10)
122
+ continue;
123
+ const name = fields[2];
124
+ if (!name || /^(loop|ram|zram|fd|sr|dm-|md|zd)/.test(name) || isPartition(name))
125
+ continue;
126
+ readBytes += (Number(fields[5]) || 0) * DISK_SECTOR_BYTES;
127
+ writeBytes += (Number(fields[9]) || 0) * DISK_SECTOR_BYTES;
128
+ }
129
+ return { readBytes, writeBytes };
130
+ }
131
+ function readDiskTotals() {
132
+ try {
133
+ return parseLinuxDiskStats(fs.readFileSync('/proc/diskstats', 'utf8'));
134
+ }
135
+ catch {
136
+ return { readBytes: 0, writeBytes: 0 };
137
+ }
138
+ }
139
+ function sampleDiskRate(ts) {
140
+ const totals = readDiskTotals();
141
+ if (!previousDiskSample) {
142
+ previousDiskSample = { ...totals, ts };
143
+ return latestDiskRate;
144
+ }
145
+ const elapsedMs = ts - previousDiskSample.ts;
146
+ if (elapsedMs < 250)
147
+ return latestDiskRate;
148
+ latestDiskRate = {
149
+ readBps: Math.max(0, (totals.readBytes - previousDiskSample.readBytes) * 1000 / elapsedMs),
150
+ writeBps: Math.max(0, (totals.writeBytes - previousDiskSample.writeBytes) * 1000 / elapsedMs),
151
+ };
152
+ previousDiskSample = { ...totals, ts };
153
+ return latestDiskRate;
154
+ }
76
155
  let previousNetworkSample = null;
77
156
  let latestNetworkRate = { rxBps: 0, txBps: 0 };
78
157
  function readNetworkTotals() {
@@ -122,22 +201,26 @@ async function fetchSnapshot() {
122
201
  }
123
202
  const s = resp.snapshot;
124
203
  const ts = Number(s.ts) || Date.now();
204
+ const cpuCount = Number(s.cpuCount) || 1;
205
+ const disk = sampleDiskRate(ts);
125
206
  const network = sampleNetworkRate(ts);
126
207
  const sample = {
127
208
  ts,
128
- procCpu: s.cpuPercent ?? 0,
209
+ procCpu: normalizeProcessCpuPercent(s.cpuPercent, cpuCount),
129
210
  sysCpu: s.system?.cpuPercent ?? 0,
130
211
  procRss: s.memory?.rss ?? 0,
131
212
  sysMemUsed: s.system?.memUsed ?? 0,
213
+ diskReadBps: disk.readBps,
214
+ diskWriteBps: disk.writeBps,
132
215
  netRxBps: network.rxBps,
133
216
  netTxBps: network.txBps,
134
- processes: Array.isArray(s.processTree?.processes) ? s.processTree.processes.map((process) => ({
217
+ processes: mergeProcessesByIdentity(Array.isArray(s.processTree?.processes) ? s.processTree.processes.map((process) => ({
135
218
  pid: Number(process.pid),
136
219
  name: String(process.name || 'process'),
137
220
  kind: String(process.kind || 'child'),
138
- cpuPercent: Number(process.cpuPercent) || 0,
221
+ cpuPercent: normalizeProcessCpuPercent(process.cpuPercent, cpuCount),
139
222
  rss: Number(process.rss) || 0,
140
- })) : [],
223
+ })) : []),
141
224
  };
142
225
  ingest(resolutions.fine, sample);
143
226
  ingest(resolutions.mid, sample);
@@ -6,11 +6,16 @@
6
6
  */
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
+ import { ipcQuery } from '../ipc-client.js';
9
10
  import { resolvePaths } from '../paths.js';
10
11
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
11
12
  import { isTransientProtocolMessage, readAllJsonlLines, readJsonFile, } from '../fs-utils.js';
12
13
  let contextModulesPromise = null;
13
14
  const sessionAdapterPromises = new Map();
15
+ let peerDisplayResolverPromise = null;
16
+ const aunGroupNameCache = new Map();
17
+ const aunGroupNameRequests = new Map();
18
+ let agentDisplayNamesCache = null;
14
19
  function getContextModules() {
15
20
  if (!contextModulesPromise) {
16
21
  contextModulesPromise = Promise.all([
@@ -21,6 +26,145 @@ function getContextModules() {
21
26
  }
22
27
  return contextModulesPromise;
23
28
  }
29
+ function getPeerDisplayResolver() {
30
+ if (!peerDisplayResolverPromise) {
31
+ peerDisplayResolverPromise = Promise.resolve()
32
+ .then(() => import(toFileUrl(resolveParentDistModule('config', 'contact-book.js'))))
33
+ .then(mod => typeof mod.resolvePeerDisplayLabel === 'function' ? mod.resolvePeerDisplayLabel : null)
34
+ .catch(() => null);
35
+ }
36
+ return peerDisplayResolverPromise;
37
+ }
38
+ export function resolveContactPeerName(resolver, identity, peerId, fallback) {
39
+ if (!resolver)
40
+ return fallback;
41
+ try {
42
+ return resolver(identity.selfAID, identity.channelType, peerId, identity.channel) || fallback;
43
+ }
44
+ catch {
45
+ return fallback;
46
+ }
47
+ }
48
+ export function aunGroupIdVariants(groupId) {
49
+ const id = String(groupId || '').trim();
50
+ if (!id)
51
+ return [];
52
+ const variants = new Set([id]);
53
+ const legacy = /^group\.agentid\.pub\/([^/]+)$/i.exec(id);
54
+ if (legacy)
55
+ variants.add(`${legacy[1]}.agentid.pub`);
56
+ const current = /^([^.]+)\.agentid\.pub$/i.exec(id);
57
+ if (current)
58
+ variants.add(`group.agentid.pub/${current[1]}`);
59
+ return [...variants];
60
+ }
61
+ export function resolveAunGroupName(names, groupId, fallback) {
62
+ for (const id of aunGroupIdVariants(groupId || '')) {
63
+ const name = names.get(id);
64
+ if (name)
65
+ return name;
66
+ }
67
+ return fallback;
68
+ }
69
+ async function fetchAunGroupNames(aid) {
70
+ const names = new Map();
71
+ let cursor = null;
72
+ let page = 0;
73
+ do {
74
+ page += 1;
75
+ const response = await ipcQuery(resolvePaths().socket, {
76
+ type: 'menu.exec',
77
+ payload: {
78
+ type: 'menu.query',
79
+ name: 'connect',
80
+ cmd: '/connect',
81
+ agent: aid,
82
+ id: `ecweb-msg-groups-${Date.now()}-${page}`,
83
+ args: { self: aid, view: 'groups', limit: 200, ...(cursor ? { cursor } : {}) },
84
+ },
85
+ auth: { source: 'ecweb', localDirect: true },
86
+ }, 10_000);
87
+ const data = response?.ok && !response.response?.error ? response.response?.data : null;
88
+ if (!data || !Array.isArray(data.items))
89
+ break;
90
+ for (const item of data.items) {
91
+ const groupId = String(item?.groupId || '').trim();
92
+ const displayName = String(item?.displayName || '').trim();
93
+ if (!groupId || !displayName)
94
+ continue;
95
+ for (const variant of aunGroupIdVariants(groupId))
96
+ names.set(variant, displayName);
97
+ }
98
+ cursor = typeof data.nextCursor === 'string' && data.nextCursor ? data.nextCursor : null;
99
+ } while (cursor && page < 20);
100
+ return names;
101
+ }
102
+ function getAunGroupNames(aid) {
103
+ const cached = aunGroupNameCache.get(aid);
104
+ if (cached && cached.expiresAt > Date.now())
105
+ return Promise.resolve(cached.names);
106
+ const pending = aunGroupNameRequests.get(aid);
107
+ if (pending)
108
+ return pending;
109
+ const request = fetchAunGroupNames(aid)
110
+ .then(names => {
111
+ aunGroupNameCache.set(aid, {
112
+ names,
113
+ expiresAt: Date.now() + (names.size ? 5 * 60_000 : 15_000),
114
+ });
115
+ return names;
116
+ })
117
+ .catch(() => new Map())
118
+ .finally(() => aunGroupNameRequests.delete(aid));
119
+ aunGroupNameRequests.set(aid, request);
120
+ return request;
121
+ }
122
+ async function hydrateAunGroupNames(chats) {
123
+ const aids = [...new Set(chats
124
+ .filter(chat => chat.channelType === 'aun' && chat.chatType === 'group')
125
+ .map(chat => chat.selfAID))];
126
+ if (!aids.length)
127
+ return;
128
+ const directories = new Map(await Promise.all(aids.map(async (aid) => [aid, await getAunGroupNames(aid)])));
129
+ for (const chat of chats) {
130
+ if (chat.channelType !== 'aun' || chat.chatType !== 'group')
131
+ continue;
132
+ const groupName = resolveAunGroupName(directories.get(chat.selfAID) || new Map(), chat.groupId, chat.groupName);
133
+ if (!groupName)
134
+ continue;
135
+ chat.groupName = groupName;
136
+ chat.peerName = groupName;
137
+ }
138
+ }
139
+ export function resolveSessionBindDisplayName(bind, resolver, aunGroupNames = new Map()) {
140
+ const isGroup = bind.chatType === 'group' || !!bind.groupId;
141
+ if (bind.channelType === 'aun' && isGroup) {
142
+ return resolveAunGroupName(aunGroupNames, bind.groupId || bind.channelId, bind.groupName || null);
143
+ }
144
+ if (isGroup)
145
+ return bind.groupName || null;
146
+ return resolveContactPeerName(resolver, {
147
+ selfAID: bind.selfAID,
148
+ channelType: bind.channelType,
149
+ channel: bind.channelKey || bind.channelType,
150
+ channelId: bind.channelId,
151
+ }, bind.peerId || bind.channelId, bind.peerName);
152
+ }
153
+ /** 为会话页绑定标签补齐联系人名或 AUN 群名,不改变原始 channelId。 */
154
+ export async function hydrateSessionBindDisplayNames(binds) {
155
+ const items = [...binds];
156
+ if (!items.length)
157
+ return;
158
+ const resolver = await getPeerDisplayResolver();
159
+ const groupAids = [...new Set(items
160
+ .filter(bind => bind.channelType === 'aun' && (bind.chatType === 'group' || !!bind.groupId))
161
+ .map(bind => bind.selfAID)
162
+ .filter(Boolean))];
163
+ const groupDirectories = new Map(await Promise.all(groupAids.map(async (aid) => [aid, await getAunGroupNames(aid)])));
164
+ for (const bind of items) {
165
+ bind.displayName = resolveSessionBindDisplayName(bind, resolver, groupDirectories.get(bind.selfAID) || new Map());
166
+ }
167
+ }
24
168
  function getSessionAdapter(baseagent) {
25
169
  if (!sessionAdapterPromises.has(baseagent)) {
26
170
  const classNames = {
@@ -67,6 +211,9 @@ function readLastJsonlObject(file) {
67
211
  const rows = readAllJsonlLines(file);
68
212
  return rows.length ? rows[rows.length - 1] : null;
69
213
  }
214
+ export function latestMessageAt(messages) {
215
+ return messages.reduce((latest, message) => Math.max(latest, Number(message.ts) || 0), 0);
216
+ }
70
217
  function loadThreadSessions(dirPath) {
71
218
  const threadsDir = path.join(dirPath, '_threads');
72
219
  const index = readJsonFile(path.join(threadsDir, 'thread-index.json')) || {};
@@ -170,6 +317,37 @@ function listConfiguredAgentAids(root) {
170
317
  }
171
318
  return aids;
172
319
  }
320
+ function shortAid(value) {
321
+ return String(value || '').split('.')[0] || String(value || '');
322
+ }
323
+ /** Returns local Agent display names, preferring the runtime selfName. */
324
+ export async function getAgentSelfNames() {
325
+ if (agentDisplayNamesCache && agentDisplayNamesCache.expiresAt > Date.now()) {
326
+ return agentDisplayNamesCache.names;
327
+ }
328
+ const names = new Map();
329
+ try {
330
+ const [agentsResp, statsResp] = await Promise.all([
331
+ ipcQuery(resolvePaths().socket, { type: 'evolagent.list' }, 4000),
332
+ ipcQuery(resolvePaths().socket, { type: 'aun-aid-stats' }, 4000),
333
+ ]);
334
+ const statsByAid = new Map((statsResp?.stats || []).map(item => [String(item?.aid || ''), item]));
335
+ for (const agent of agentsResp?.agents || []) {
336
+ const aid = String(agent?.aid || '').trim();
337
+ if (!aid)
338
+ continue;
339
+ const stats = statsByAid.get(aid);
340
+ const displayName = String(stats?.selfName || agent?.displayName || shortAid(aid)).trim();
341
+ if (displayName)
342
+ names.set(aid, displayName);
343
+ }
344
+ }
345
+ catch {
346
+ // daemon unavailable: leave the map empty and use the AID fallback below
347
+ }
348
+ agentDisplayNamesCache = { expiresAt: Date.now() + 5000, names };
349
+ return names;
350
+ }
173
351
  function decodeDirSegment(seg) {
174
352
  return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
175
353
  }
@@ -289,7 +467,7 @@ function chatDisplayId(active, messages) {
289
467
  groupName: null,
290
468
  };
291
469
  }
292
- function loadChats() {
470
+ function loadChats(peerDisplayResolver) {
293
471
  const paths = resolvePaths();
294
472
  const sessionsDir = paths.sessionsDir;
295
473
  const configuredAids = listConfiguredAgentAids(paths.root);
@@ -308,6 +486,9 @@ function loadChats() {
308
486
  continue;
309
487
  const chatType = String(active.chatType || messages.find(m => m.chatType)?.chatType || 'private');
310
488
  const display = chatDisplayId({ ...active, chatType, channelId }, messages);
489
+ const peerName = chatType === 'group'
490
+ ? display.peerName
491
+ : resolveContactPeerName(peerDisplayResolver, identity, display.peerId, display.peerName);
311
492
  const threadSessions = loadThreadSessions(dirPath);
312
493
  const threadById = new Map(threadSessions.map(item => [item.threadId, item]));
313
494
  const mainSessionIds = loadMainSessionIds(dirPath, active);
@@ -316,8 +497,9 @@ function loadChats() {
316
497
  continue;
317
498
  const thread = item.threadId ? threadById.get(item.threadId) : undefined;
318
499
  const partitionActive = item.partition === 'thread' ? thread?.active || {} : item.partition === 'main' ? active : {};
319
- const lastMsgAt = item.messages.reduce((max, message) => Math.max(max, message.ts || 0), 0);
320
- const updatedAt = Math.max(Number(partitionActive.updatedAt || 0), lastMsgAt);
500
+ // active.updatedAt also changes for session metadata maintenance (for example,
501
+ // a project-path migration), so it is not a conversation activity timestamp.
502
+ const updatedAt = latestMessageAt(item.messages);
321
503
  chats.push({
322
504
  id: safeId(`${selfAID}\n${channelType}\n${channelId}\n${chatType}\n${item.partition}\n${item.threadId || ''}`),
323
505
  dirPath,
@@ -327,7 +509,7 @@ function loadChats() {
327
509
  selfAID,
328
510
  chatType,
329
511
  peerId: display.peerId,
330
- peerName: display.peerName,
512
+ peerName,
331
513
  groupId: display.groupId,
332
514
  groupName: display.groupName,
333
515
  partition: item.partition,
@@ -349,7 +531,7 @@ function scopeId(chat) {
349
531
  function legacyChannelScopeId(chat) {
350
532
  return safeId(`${chat.selfAID}\n${chat.channelType}\n${chat.channel}`);
351
533
  }
352
- function buildScopes(chats) {
534
+ function buildScopes(chats, agentDisplayNames = new Map()) {
353
535
  const map = new Map();
354
536
  const seenChats = new Map();
355
537
  const seenChannels = new Map();
@@ -362,6 +544,7 @@ function buildScopes(chats) {
362
544
  id,
363
545
  aid: id,
364
546
  selfAID: chat.selfAID,
547
+ displayName: agentDisplayNames.get(chat.selfAID) || shortAid(chat.selfAID),
365
548
  totalIn: 0,
366
549
  totalOut: 0,
367
550
  peerCount: 0,
@@ -562,8 +745,9 @@ async function buildSessionContext(chat) {
562
745
  };
563
746
  }
564
747
  async function buildSnapshot(params) {
565
- const chats = loadChats();
566
- const scopes = buildScopes(chats);
748
+ const peerDisplayResolver = await getPeerDisplayResolver();
749
+ const chats = loadChats(peerDisplayResolver);
750
+ const scopes = buildScopes(chats, await getAgentSelfNames());
567
751
  const requestedScope = params.scope || params.aid || null;
568
752
  const scope = requestedScope && !scopes.some(s => s.id === requestedScope)
569
753
  ? (chats.find(chat => legacyChannelScopeId(chat) === requestedScope)
@@ -572,6 +756,8 @@ async function buildSnapshot(params) {
572
756
  : requestedScope;
573
757
  const peer = params.peer || null;
574
758
  const inScope = selectedChats(chats, scope);
759
+ if (scope)
760
+ await hydrateAunGroupNames(inScope);
575
761
  const peers = scope ? mergePeerInfo(inScope) : [];
576
762
  const messages = scope ? messagesFor(inScope, peer) : [];
577
763
  const currentChat = peer ? inScope.find(chat => chatKey(chat) === peer) : undefined;
@@ -661,30 +661,8 @@ export const relationRolesSource = {
661
661
  return { agents: [], conversations: [], error: String(err) };
662
662
  }
663
663
  },
664
- subscribe(_params, push) {
665
- let lastJson = '';
666
- let stopped = false;
667
- const tick = async () => {
668
- if (stopped)
669
- return;
670
- try {
671
- const snap = await buildSnapshot(_params.__menuAuth ?? {});
672
- const json = JSON.stringify(snap);
673
- if (json !== lastJson) {
674
- lastJson = json;
675
- push(snap);
676
- }
677
- }
678
- catch (err) {
679
- console.error('[relation-roles] polling error:', err);
680
- }
681
- };
682
- tick();
683
- const timer = setInterval(tick, 2000);
684
- return () => {
685
- stopped = true;
686
- clearInterval(timer);
687
- };
664
+ subscribe(_params, _push) {
665
+ return () => { };
688
666
  },
689
667
  };
690
668
  function requireScope(body) {