ec-web 0.0.4 → 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 ──
@@ -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 = {
@@ -173,6 +317,37 @@ function listConfiguredAgentAids(root) {
173
317
  }
174
318
  return aids;
175
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
+ }
176
351
  function decodeDirSegment(seg) {
177
352
  return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
178
353
  }
@@ -292,7 +467,7 @@ function chatDisplayId(active, messages) {
292
467
  groupName: null,
293
468
  };
294
469
  }
295
- function loadChats() {
470
+ function loadChats(peerDisplayResolver) {
296
471
  const paths = resolvePaths();
297
472
  const sessionsDir = paths.sessionsDir;
298
473
  const configuredAids = listConfiguredAgentAids(paths.root);
@@ -311,6 +486,9 @@ function loadChats() {
311
486
  continue;
312
487
  const chatType = String(active.chatType || messages.find(m => m.chatType)?.chatType || 'private');
313
488
  const display = chatDisplayId({ ...active, chatType, channelId }, messages);
489
+ const peerName = chatType === 'group'
490
+ ? display.peerName
491
+ : resolveContactPeerName(peerDisplayResolver, identity, display.peerId, display.peerName);
314
492
  const threadSessions = loadThreadSessions(dirPath);
315
493
  const threadById = new Map(threadSessions.map(item => [item.threadId, item]));
316
494
  const mainSessionIds = loadMainSessionIds(dirPath, active);
@@ -331,7 +509,7 @@ function loadChats() {
331
509
  selfAID,
332
510
  chatType,
333
511
  peerId: display.peerId,
334
- peerName: display.peerName,
512
+ peerName,
335
513
  groupId: display.groupId,
336
514
  groupName: display.groupName,
337
515
  partition: item.partition,
@@ -353,7 +531,7 @@ function scopeId(chat) {
353
531
  function legacyChannelScopeId(chat) {
354
532
  return safeId(`${chat.selfAID}\n${chat.channelType}\n${chat.channel}`);
355
533
  }
356
- function buildScopes(chats) {
534
+ function buildScopes(chats, agentDisplayNames = new Map()) {
357
535
  const map = new Map();
358
536
  const seenChats = new Map();
359
537
  const seenChannels = new Map();
@@ -366,6 +544,7 @@ function buildScopes(chats) {
366
544
  id,
367
545
  aid: id,
368
546
  selfAID: chat.selfAID,
547
+ displayName: agentDisplayNames.get(chat.selfAID) || shortAid(chat.selfAID),
369
548
  totalIn: 0,
370
549
  totalOut: 0,
371
550
  peerCount: 0,
@@ -566,8 +745,9 @@ async function buildSessionContext(chat) {
566
745
  };
567
746
  }
568
747
  async function buildSnapshot(params) {
569
- const chats = loadChats();
570
- const scopes = buildScopes(chats);
748
+ const peerDisplayResolver = await getPeerDisplayResolver();
749
+ const chats = loadChats(peerDisplayResolver);
750
+ const scopes = buildScopes(chats, await getAgentSelfNames());
571
751
  const requestedScope = params.scope || params.aid || null;
572
752
  const scope = requestedScope && !scopes.some(s => s.id === requestedScope)
573
753
  ? (chats.find(chat => legacyChannelScopeId(chat) === requestedScope)
@@ -576,6 +756,8 @@ async function buildSnapshot(params) {
576
756
  : requestedScope;
577
757
  const peer = params.peer || null;
578
758
  const inScope = selectedChats(chats, scope);
759
+ if (scope)
760
+ await hydrateAunGroupNames(inScope);
579
761
  const peers = scope ? mergePeerInfo(inScope) : [];
580
762
  const messages = scope ? messagesFor(inScope, peer) : [];
581
763
  const currentChat = peer ? inScope.find(chat => chatKey(chat) === peer) : undefined;
@@ -12,7 +12,9 @@ import path from 'path';
12
12
  import os from 'os';
13
13
  import { createRequire } from 'module';
14
14
  import { resolvePaths } from '../paths.js';
15
- import { encodePath, scanChatDirs, readJsonFile } from '../fs-utils.js';
15
+ import { encodePath } from '../fs-utils.js';
16
+ import { getAgentSelfNames } from './msg.js';
17
+ import { buildSessionBindMap, classifyTranscriptSource, isEvolcoreRoutedMessage, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
16
18
  const requireFromHere = createRequire(import.meta.url);
17
19
  let _dlog = null;
18
20
  export function setDebugLog(log) { _dlog = log; }
@@ -107,7 +109,7 @@ function listProjects() {
107
109
  }
108
110
  }
109
111
  // ── 会话列表 ──
110
- const CACHE_VERSION = 1;
112
+ const CACHE_VERSION = 2;
111
113
  const _metaCache = new Map();
112
114
  function cacheDir(encoded) {
113
115
  const dataDir = resolvePaths().dataDir;
@@ -170,6 +172,7 @@ function extractMetaFromRollout(file, id) {
170
172
  }
171
173
  catch { }
172
174
  let title = '', firstUser = '', gitBranch = '', version = '', userMsgs = 0, totalMsgs = 0;
175
+ let hasRealUserInput = false;
173
176
  const lines = raw.split('\n');
174
177
  for (const line of lines) {
175
178
  if (!line.trim())
@@ -187,6 +190,8 @@ function extractMetaFromRollout(file, id) {
187
190
  if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
188
191
  totalMsgs++;
189
192
  userMsgs++;
193
+ if (!isSyntheticCliPrompt(event.payload.message, 'codex'))
194
+ hasRealUserInput = true;
190
195
  if (!firstUser) {
191
196
  const text = event.payload.message.trim().replace(/\s+/g, ' ');
192
197
  firstUser = text.substring(0, 120);
@@ -216,15 +221,34 @@ function extractMetaFromRollout(file, id) {
216
221
  totalMsgs,
217
222
  sizeKB: Math.round(stat.size / 1024),
218
223
  lastActivity: stat.mtimeMs,
224
+ hasRealUserInput,
225
+ backendSource: '',
226
+ threadSource: '',
227
+ evolcoreRouted: false,
219
228
  };
220
229
  }
230
+ function withThreadSource(meta, row) {
231
+ return {
232
+ ...meta,
233
+ backendSource: typeof row.source === 'string' ? row.source : '',
234
+ threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
235
+ evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
236
+ };
237
+ }
238
+ function isCodexCliCandidate(meta) {
239
+ if (meta.threadSource === 'subagent')
240
+ return false;
241
+ if (meta.backendSource !== 'cli' && meta.backendSource !== 'vscode')
242
+ return false;
243
+ return meta.hasRealUserInput && !meta.evolcoreRouted;
244
+ }
221
245
  function listTranscripts(encoded, cwd) {
222
246
  const db = getDb();
223
247
  if (!db)
224
248
  return [];
225
249
  try {
226
250
  const rows = db.prepare(`
227
- SELECT id, title, first_user_message, updated_at, rollout_path
251
+ SELECT id, title, first_user_message, updated_at, rollout_path, source, thread_source
228
252
  FROM threads
229
253
  WHERE cwd = ? AND archived = 0
230
254
  ORDER BY updated_at DESC
@@ -248,6 +272,10 @@ function listTranscripts(encoded, cwd) {
248
272
  totalMsgs: 0,
249
273
  sizeKB: 0,
250
274
  lastActivity: (row.updated_at || 0) * 1000,
275
+ hasRealUserInput: !!firstUser && !isSyntheticCliPrompt(firstUser, 'codex'),
276
+ backendSource: typeof row.source === 'string' ? row.source : '',
277
+ threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
278
+ evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
251
279
  });
252
280
  continue;
253
281
  }
@@ -261,21 +289,21 @@ function listTranscripts(encoded, cwd) {
261
289
  const mtime = stat.mtimeMs;
262
290
  const mem = _metaCache.get(rolloutPath);
263
291
  if (mem && mem.mtime === mtime) {
264
- out.push(mem.meta);
292
+ out.push(withThreadSource(mem.meta, row));
265
293
  hitMem++;
266
294
  continue;
267
295
  }
268
296
  const disk = readDiskCache(encoded, id, mtime, stat.size);
269
297
  if (disk) {
270
298
  _metaCache.set(rolloutPath, { mtime, meta: disk });
271
- out.push(disk);
299
+ out.push(withThreadSource(disk, row));
272
300
  hitDisk++;
273
301
  continue;
274
302
  }
275
303
  const meta = extractMetaFromRollout(rolloutPath, id);
276
304
  _metaCache.set(rolloutPath, { mtime, meta });
277
305
  writeDiskCache(encoded, id, mtime, stat.size, meta);
278
- out.push(meta);
306
+ out.push(withThreadSource(meta, row));
279
307
  parsed++;
280
308
  }
281
309
  dlog(`[codex] listTranscripts ${cwd.slice(-20)}: ${rows.length} threads (mem=${hitMem} disk=${hitDisk} parsed=${parsed})`);
@@ -288,25 +316,7 @@ function listTranscripts(encoded, cwd) {
288
316
  }
289
317
  // ── 绑定关系 ──
290
318
  export function buildBindMap() {
291
- const map = new Map();
292
- try {
293
- const p = resolvePaths();
294
- for (const dir of scanChatDirs(p.sessionsDir)) {
295
- const active = readJsonFile(path.join(dir.dirPath, 'active.json'));
296
- if (!active || !active.agentSessionId)
297
- continue;
298
- map.set(active.agentSessionId, {
299
- channelType: active.channelType || dir.channelType,
300
- channelId: active.channelId || dir.channelId,
301
- selfAID: active.selfAID || dir.selfAID,
302
- peerName: (active.metadata && active.metadata.peerName) || null,
303
- name: active.name,
304
- updatedAt: active.updatedAt || 0,
305
- });
306
- }
307
- }
308
- catch { }
309
- return map;
319
+ return buildSessionBindMap();
310
320
  }
311
321
  // ── 项目解析 ──
312
322
  function resolveProject(params, projects) {
@@ -611,7 +621,7 @@ function readTranscriptFile(threadId, cwd) {
611
621
  };
612
622
  }
613
623
  // ── Snapshot ──
614
- function buildSnapshot(params) {
624
+ async function buildSnapshot(params) {
615
625
  const projects = listProjects();
616
626
  const bindMap = buildBindMap();
617
627
  const project = resolveProject(params, projects);
@@ -626,13 +636,15 @@ function buildSnapshot(params) {
626
636
  };
627
637
  }
628
638
  const metas = listTranscripts(project.encoded, project.cwd);
639
+ const selfNames = await getAgentSelfNames();
629
640
  const transcripts = metas.map(m => {
630
641
  const bind = bindMap.get(m.id);
631
642
  return {
632
643
  ...m,
644
+ sourceType: classifyTranscriptSource(bind, isCodexCliCandidate(m)),
633
645
  bound: !!bind,
634
646
  boundChannel: bind?.channelType ?? null,
635
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
647
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
636
648
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
637
649
  };
638
650
  });
@@ -655,6 +667,7 @@ function buildSnapshot(params) {
655
667
  }
656
668
  const detail = readTranscriptFile(sessionId, project.cwd);
657
669
  const bind = bindMap.get(sessionId);
670
+ const transcript = metas.find(meta => meta.id === sessionId);
658
671
  const header = {
659
672
  sessionId,
660
673
  title: detail.title,
@@ -670,9 +683,10 @@ function buildSnapshot(params) {
670
683
  outputTokens: detail.outputTokens,
671
684
  contextTokens: detail.contextTokens,
672
685
  costUsd: detail.costUsd,
686
+ sourceType: classifyTranscriptSource(bind, transcript ? isCodexCliCandidate(transcript) : false),
673
687
  bound: !!bind,
674
688
  boundChannel: bind?.channelType ?? null,
675
- boundPeer: bind ? (bind.peerName || bind.channelId) : null,
689
+ boundSelf: resolveSessionBindSelfName(bind, selfNames),
676
690
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
677
691
  };
678
692
  return {
@@ -699,7 +713,7 @@ export function subscribeCodex(params, push) {
699
713
  clearTimeout(debounce);
700
714
  debounce = setTimeout(() => {
701
715
  try {
702
- push(buildSnapshot(params));
716
+ void buildSnapshot(params).then(push).catch(() => { });
703
717
  }
704
718
  catch { }
705
719
  }, 150);