ec-web 0.0.5 → 0.0.6

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
@@ -20,7 +20,7 @@ import { ipcQuery } from './ipc-client.js';
20
20
  import { setDebugLog, dlog } from './debug-log.js';
21
21
  import { aidSource } from './sources/aid.js';
22
22
  import { msgSource } from './sources/msg.js';
23
- import { sessionSource, buildBindMap } from './sources/session.js';
23
+ import { sessionSource, buildBindMap, readClaudeSessionResult } from './sources/session.js';
24
24
  import { cacheSource } from './sources/cache.js';
25
25
  import { systemSource } from './sources/system.js';
26
26
  import { triggersSource } from './sources/triggers.js';
@@ -34,6 +34,7 @@ import { relationRolesSource, handleRelationRolesApi, handlePeerRoleApi } from '
34
34
  import { roleDefinitionsSource, handleRoleDefinitionsApi } from './sources/role-definitions.js';
35
35
  import { handleModelsApi } from './sources/models.js';
36
36
  import { detectBaseAgents } from './sources/baseagent-detector.js';
37
+ import { readCodexSessionResult } from './sources/session-codex.js';
37
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
39
  const STATIC_DIR = path.join(__dirname, 'static');
39
40
  const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
@@ -553,8 +554,14 @@ function handleConnection(ws, req, log) {
553
554
  params.aid = msg.aid;
554
555
  if (msg.peer)
555
556
  params.peer = msg.peer;
557
+ if (msg.before != null)
558
+ params.before = msg.before;
556
559
  if (msg.sessionId)
557
560
  params.sessionId = msg.sessionId;
561
+ if (msg.turnBefore != null)
562
+ params.turnBefore = msg.turnBefore;
563
+ if (msg.turnLimit != null)
564
+ params.turnLimit = msg.turnLimit;
558
565
  if (msg.project)
559
566
  params.project = msg.project;
560
567
  if (msg.agent)
@@ -569,6 +576,14 @@ function handleConnection(ws, req, log) {
569
576
  await switchSubscription(msg.view, params);
570
577
  return;
571
578
  }
579
+ if (msg.type === 'session.result') {
580
+ const params = msg.params && typeof msg.params === 'object' ? msg.params : {};
581
+ const result = params.baseagent === 'codex'
582
+ ? readCodexSessionResult(params)
583
+ : readClaudeSessionResult(params);
584
+ send({ type: 'session.result', requestId: msg.requestId, data: result == null ? null : { text: result } });
585
+ return;
586
+ }
572
587
  if (msg.type === 'menu' && msg.payload) {
573
588
  const p = resolvePaths();
574
589
  const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
@@ -16,6 +16,7 @@ let peerDisplayResolverPromise = null;
16
16
  const aunGroupNameCache = new Map();
17
17
  const aunGroupNameRequests = new Map();
18
18
  let agentDisplayNamesCache = null;
19
+ const messageFileCache = new Map();
19
20
  function getContextModules() {
20
21
  if (!contextModulesPromise) {
21
22
  contextModulesPromise = Promise.all([
@@ -119,13 +120,27 @@ function getAunGroupNames(aid) {
119
120
  aunGroupNameRequests.set(aid, request);
120
121
  return request;
121
122
  }
122
- async function hydrateAunGroupNames(chats) {
123
+ async function hydrateAunGroupNames(chats, waitForRefresh = false) {
123
124
  const aids = [...new Set(chats
124
125
  .filter(chat => chat.channelType === 'aun' && chat.chatType === 'group')
125
126
  .map(chat => chat.selfAID))];
126
127
  if (!aids.length)
127
128
  return;
128
- const directories = new Map(await Promise.all(aids.map(async (aid) => [aid, await getAunGroupNames(aid)])));
129
+ const directories = new Map();
130
+ if (waitForRefresh) {
131
+ for (const [aid, names] of await Promise.all(aids.map(async (aid) => [aid, await getAunGroupNames(aid)]))) {
132
+ directories.set(aid, names);
133
+ }
134
+ }
135
+ else {
136
+ for (const aid of aids) {
137
+ const cached = aunGroupNameCache.get(aid);
138
+ if (cached)
139
+ directories.set(aid, cached.names);
140
+ if (!cached || cached.expiresAt <= Date.now())
141
+ void getAunGroupNames(aid);
142
+ }
143
+ }
129
144
  for (const chat of chats) {
130
145
  if (chat.channelType !== 'aun' || chat.chatType !== 'group')
131
146
  continue;
@@ -207,6 +222,131 @@ function findMessageFiles(root) {
207
222
  visit(root);
208
223
  return out;
209
224
  }
225
+ function fileSignature(file) {
226
+ try {
227
+ const stat = fs.statSync(file);
228
+ return `${stat.mtimeMs}:${stat.size}`;
229
+ }
230
+ catch {
231
+ return '';
232
+ }
233
+ }
234
+ function chatMetadataSignature(dirPath) {
235
+ const parts = [
236
+ `active:${fileSignature(path.join(dirPath, 'active.json'))}`,
237
+ ];
238
+ let rootEntries = [];
239
+ try {
240
+ rootEntries = fs.readdirSync(dirPath, { withFileTypes: true });
241
+ }
242
+ catch { }
243
+ for (const entry of rootEntries) {
244
+ if (entry.isFile() && entry.name.startsWith('meta_') && entry.name.endsWith('.jsonl')) {
245
+ parts.push(`${entry.name}:${fileSignature(path.join(dirPath, entry.name))}`);
246
+ }
247
+ }
248
+ const threadsDir = path.join(dirPath, '_threads');
249
+ let threadEntries = [];
250
+ try {
251
+ threadEntries = fs.readdirSync(threadsDir, { withFileTypes: true });
252
+ }
253
+ catch { }
254
+ for (const entry of threadEntries) {
255
+ if (entry.isFile() && (entry.name === 'thread-index.json' || entry.name.endsWith('.jsonl'))) {
256
+ parts.push(`threads/${entry.name}:${fileSignature(path.join(threadsDir, entry.name))}`);
257
+ }
258
+ }
259
+ return parts.sort().join('|');
260
+ }
261
+ function appendMessageRows(file, cached, metrics) {
262
+ let stat;
263
+ try {
264
+ stat = fs.statSync(file);
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ if (stat.size < cached.offset || (stat.size === cached.offset && cached.mtimeMs && stat.mtimeMs !== cached.mtimeMs)) {
270
+ cached.offset = 0;
271
+ cached.messages = [];
272
+ }
273
+ if (stat.size === cached.offset)
274
+ return false;
275
+ const bytes = stat.size - cached.offset;
276
+ const buffer = Buffer.allocUnsafe(bytes);
277
+ let fd = null;
278
+ try {
279
+ fd = fs.openSync(file, 'r');
280
+ fs.readSync(fd, buffer, 0, bytes, cached.offset);
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ finally {
286
+ if (fd != null)
287
+ try {
288
+ fs.closeSync(fd);
289
+ }
290
+ catch { }
291
+ }
292
+ let completeBytes = buffer.length;
293
+ const lastBreak = buffer.lastIndexOf(0x0a);
294
+ if (lastBreak !== buffer.length - 1) {
295
+ const trailing = buffer.subarray(lastBreak + 1).toString('utf8').trim();
296
+ try {
297
+ JSON.parse(trailing);
298
+ }
299
+ catch {
300
+ completeBytes = lastBreak + 1;
301
+ }
302
+ }
303
+ if (completeBytes <= 0)
304
+ return false;
305
+ let parsedLines = 0;
306
+ for (const line of buffer.subarray(0, completeBytes).toString('utf8').split(/\r?\n/)) {
307
+ if (!line)
308
+ continue;
309
+ try {
310
+ const message = JSON.parse(line);
311
+ parsedLines++;
312
+ if (!isTransientProtocolMessage(message))
313
+ cached.messages.push(message);
314
+ }
315
+ catch { }
316
+ }
317
+ cached.offset += completeBytes;
318
+ cached.mtimeMs = stat.mtimeMs;
319
+ metrics.changedFiles++;
320
+ metrics.parsedLines += parsedLines;
321
+ metrics.parsedBytes += completeBytes;
322
+ return true;
323
+ }
324
+ function cachedMessageFile(file, metrics) {
325
+ let cached = messageFileCache.get(file);
326
+ if (!cached) {
327
+ cached = {
328
+ offset: 0,
329
+ mtimeMs: 0,
330
+ messages: [],
331
+ active: {},
332
+ threadSessions: [],
333
+ mainSessionIds: new Set(),
334
+ metadataSignature: '',
335
+ };
336
+ messageFileCache.set(file, cached);
337
+ }
338
+ appendMessageRows(file, cached, metrics);
339
+ const dirPath = path.dirname(file);
340
+ const metadataSignature = chatMetadataSignature(dirPath);
341
+ if (metadataSignature !== cached.metadataSignature) {
342
+ cached.active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
343
+ cached.threadSessions = loadThreadSessions(dirPath);
344
+ cached.mainSessionIds = loadMainSessionIds(dirPath, cached.active);
345
+ cached.metadataSignature = metadataSignature;
346
+ metrics.metadataRefreshes++;
347
+ }
348
+ return cached;
349
+ }
210
350
  function readLastJsonlObject(file) {
211
351
  const rows = readAllJsonlLines(file);
212
352
  return rows.length ? rows[rows.length - 1] : null;
@@ -467,17 +607,22 @@ function chatDisplayId(active, messages) {
467
607
  groupName: null,
468
608
  };
469
609
  }
470
- function loadChats(peerDisplayResolver) {
610
+ function loadChats(peerDisplayResolver, metrics) {
471
611
  const paths = resolvePaths();
472
612
  const sessionsDir = paths.sessionsDir;
473
613
  const configuredAids = listConfiguredAgentAids(paths.root);
474
614
  const files = findMessageFiles(sessionsDir);
615
+ const liveFiles = new Set(files);
616
+ for (const file of messageFileCache.keys()) {
617
+ if (!liveFiles.has(file))
618
+ messageFileCache.delete(file);
619
+ }
620
+ metrics.files = files.length;
475
621
  const chats = [];
476
622
  for (const file of files) {
477
623
  const dirPath = path.dirname(file);
478
- const active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
479
- const messages = readAllJsonlLines(file)
480
- .filter(m => !isTransientProtocolMessage(m));
624
+ const cached = cachedMessageFile(file, metrics);
625
+ const { active, messages } = cached;
481
626
  if (!messages.length)
482
627
  continue;
483
628
  const identity = normalizeChatIdentity(sessionsDir, dirPath, active, messages);
@@ -489,9 +634,9 @@ function loadChats(peerDisplayResolver) {
489
634
  const peerName = chatType === 'group'
490
635
  ? display.peerName
491
636
  : resolveContactPeerName(peerDisplayResolver, identity, display.peerId, display.peerName);
492
- const threadSessions = loadThreadSessions(dirPath);
637
+ const threadSessions = cached.threadSessions;
493
638
  const threadById = new Map(threadSessions.map(item => [item.threadId, item]));
494
- const mainSessionIds = loadMainSessionIds(dirPath, active);
639
+ const mainSessionIds = cached.mainSessionIds;
495
640
  for (const item of partitionMessages(messages, threadSessions, mainSessionIds)) {
496
641
  if (!item.messages.length)
497
642
  continue;
@@ -650,7 +795,7 @@ function selectedChats(chats, scope) {
650
795
  return [];
651
796
  return chats.filter(chat => scopeId(chat) === scope);
652
797
  }
653
- function messagesFor(chats, peer) {
798
+ function messagesFor(chats, peer, before, limit) {
654
799
  const selected = peer
655
800
  ? chats.filter(chat => chatKey(chat) === peer)
656
801
  : chats;
@@ -665,7 +810,13 @@ function messagesFor(chats, peer) {
665
810
  threadName: chat.threadName,
666
811
  })));
667
812
  messages.sort((a, b) => (a.ts || 0) - (b.ts || 0));
668
- return messages.length > 1000 ? messages.slice(-1000) : messages;
813
+ const end = before == null ? messages.length : Math.min(Math.max(before, 0), messages.length);
814
+ const start = Math.max(0, end - limit);
815
+ return {
816
+ messages: messages.slice(start, end),
817
+ nextBefore: start > 0 ? start : null,
818
+ hasMore: start > 0,
819
+ };
669
820
  }
670
821
  function latestInbound(chat) {
671
822
  for (let i = chat.messages.length - 1; i >= 0; i--) {
@@ -745,8 +896,20 @@ async function buildSessionContext(chat) {
745
896
  };
746
897
  }
747
898
  async function buildSnapshot(params) {
899
+ const startedAt = Date.now();
900
+ const metrics = {
901
+ files: 0,
902
+ changedFiles: 0,
903
+ parsedLines: 0,
904
+ parsedBytes: 0,
905
+ metadataRefreshes: 0,
906
+ scanMs: 0,
907
+ totalMs: 0,
908
+ };
748
909
  const peerDisplayResolver = await getPeerDisplayResolver();
749
- const chats = loadChats(peerDisplayResolver);
910
+ const scanStartedAt = Date.now();
911
+ const chats = loadChats(peerDisplayResolver, metrics);
912
+ metrics.scanMs = Date.now() - scanStartedAt;
750
913
  const scopes = buildScopes(chats, await getAgentSelfNames());
751
914
  const requestedScope = params.scope || params.aid || null;
752
915
  const scope = requestedScope && !scopes.some(s => s.id === requestedScope)
@@ -759,18 +922,30 @@ async function buildSnapshot(params) {
759
922
  if (scope)
760
923
  await hydrateAunGroupNames(inScope);
761
924
  const peers = scope ? mergePeerInfo(inScope) : [];
762
- const messages = scope ? messagesFor(inScope, peer) : [];
925
+ const requestedLimit = Number(params.limit);
926
+ const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(Math.trunc(requestedLimit), 20), 200) : 100;
927
+ const requestedBefore = params.before == null ? Number.NaN : Number(params.before);
928
+ const before = Number.isFinite(requestedBefore) && requestedBefore >= 0 ? Math.trunc(requestedBefore) : null;
929
+ const page = scope && peer ? messagesFor(inScope, peer, before, limit) : { messages: [], nextBefore: null, hasMore: false };
763
930
  const currentChat = peer ? inScope.find(chat => chatKey(chat) === peer) : undefined;
764
931
  const sessionContext = await buildSessionContext(currentChat);
932
+ metrics.totalMs = Date.now() - startedAt;
765
933
  return {
766
934
  scopes,
767
935
  aids: scopes,
768
936
  peers,
769
- messages,
937
+ messages: page.messages,
938
+ messagePage: {
939
+ limit,
940
+ before,
941
+ nextBefore: page.nextBefore,
942
+ hasMore: page.hasMore,
943
+ },
770
944
  scope,
771
945
  aid: scope,
772
946
  peer,
773
947
  sessionContext,
948
+ metrics,
774
949
  };
775
950
  }
776
951
  export const msgSource = {
@@ -14,6 +14,7 @@ import { createRequire } from 'module';
14
14
  import { resolvePaths } from '../paths.js';
15
15
  import { encodePath } from '../fs-utils.js';
16
16
  import { getAgentSelfNames } from './msg.js';
17
+ import { parseTranscriptResultKey, previewLargeToolResults, windowTranscriptTurns } from './transcript-window.js';
17
18
  import { buildSessionBindMap, classifyTranscriptSource, isEvolcoreRoutedMessage, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
18
19
  const requireFromHere = createRequire(import.meta.url);
19
20
  let _dlog = null;
@@ -111,6 +112,8 @@ function listProjects() {
111
112
  // ── 会话列表 ──
112
113
  const CACHE_VERSION = 2;
113
114
  const _metaCache = new Map();
115
+ const _detailCache = new Map();
116
+ const MAX_DETAIL_CACHE_ENTRIES = 6;
114
117
  function cacheDir(encoded) {
115
118
  const dataDir = resolvePaths().dataDir;
116
119
  return path.join(dataDir, 'ecweb-cache', 'codex', encoded);
@@ -134,6 +137,16 @@ function writeDiskCache(encoded, id, mtime, size, meta) {
134
137
  }
135
138
  catch { }
136
139
  }
140
+ function cacheTranscriptDetail(file, mtime, size, detail) {
141
+ _detailCache.delete(file);
142
+ _detailCache.set(file, { mtime, size, detail });
143
+ while (_detailCache.size > MAX_DETAIL_CACHE_ENTRIES) {
144
+ const oldest = _detailCache.keys().next().value;
145
+ if (!oldest)
146
+ break;
147
+ _detailCache.delete(oldest);
148
+ }
149
+ }
137
150
  function pruneCacheFiles(encoded, validIds) {
138
151
  const dir = cacheDir(encoded);
139
152
  try {
@@ -462,7 +475,7 @@ export function parseCodexToolEvent(event) {
462
475
  }
463
476
  return null;
464
477
  }
465
- function readTranscriptFile(threadId, cwd) {
478
+ function readTranscriptFile(threadId, cwd, readStats) {
466
479
  const empty = {
467
480
  turns: [],
468
481
  totalTurns: 0,
@@ -495,6 +508,21 @@ function readTranscriptFile(threadId, cwd) {
495
508
  }
496
509
  if (!fs.existsSync(rolloutPath))
497
510
  return empty;
511
+ let stat;
512
+ try {
513
+ stat = fs.statSync(rolloutPath);
514
+ }
515
+ catch {
516
+ return empty;
517
+ }
518
+ const cached = _detailCache.get(rolloutPath);
519
+ if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
520
+ _detailCache.delete(rolloutPath);
521
+ _detailCache.set(rolloutPath, cached);
522
+ if (readStats)
523
+ readStats.detailCacheHit = true;
524
+ return { ...cached.detail, title: empty.title || cached.detail.title, cwd };
525
+ }
498
526
  let raw;
499
527
  try {
500
528
  raw = fs.readFileSync(rolloutPath, 'utf-8');
@@ -602,9 +630,8 @@ function readTranscriptFile(threadId, cwd) {
602
630
  }
603
631
  }
604
632
  }
605
- const shown = turns.length > 500 ? turns.slice(-500) : turns;
606
- return {
607
- turns: shown,
633
+ const detail = {
634
+ turns,
608
635
  totalTurns: turns.length,
609
636
  userMsgs,
610
637
  totalMsgs,
@@ -619,13 +646,41 @@ function readTranscriptFile(threadId, cwd) {
619
646
  title: empty.title,
620
647
  cwd,
621
648
  };
649
+ cacheTranscriptDetail(rolloutPath, stat.mtimeMs, stat.size, detail);
650
+ return detail;
651
+ }
652
+ export function readCodexSessionResult(params) {
653
+ const projects = listProjects();
654
+ const project = resolveProject(params, projects);
655
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
656
+ const key = parseTranscriptResultKey(params.resultKey);
657
+ if (!project || !sessionId || !key || !/^[A-Za-z0-9-]+$/.test(sessionId))
658
+ return null;
659
+ const detail = readTranscriptFile(sessionId, project.cwd);
660
+ const block = detail.turns[key[0]]?.blocks?.[key[1]];
661
+ return block?.kind === 'tool_result' && typeof block.text === 'string' ? block.text : null;
622
662
  }
623
663
  // ── Snapshot ──
624
664
  async function buildSnapshot(params) {
665
+ const startedAt = Date.now();
666
+ const metrics = {
667
+ baseagent: 'codex',
668
+ projectMs: 0,
669
+ bindingsMs: 0,
670
+ transcriptListMs: 0,
671
+ detailMs: 0,
672
+ totalMs: 0,
673
+ detailCacheHit: false,
674
+ };
675
+ const projectStartedAt = Date.now();
625
676
  const projects = listProjects();
677
+ metrics.projectMs = Date.now() - projectStartedAt;
678
+ const bindingsStartedAt = Date.now();
626
679
  const bindMap = buildBindMap();
680
+ metrics.bindingsMs = Date.now() - bindingsStartedAt;
627
681
  const project = resolveProject(params, projects);
628
682
  if (!project) {
683
+ metrics.totalMs = Date.now() - startedAt;
629
684
  return {
630
685
  baseagent: 'codex',
631
686
  projects: [],
@@ -633,12 +688,16 @@ async function buildSnapshot(params) {
633
688
  transcripts: [],
634
689
  turns: [],
635
690
  sessionId: null,
691
+ metrics,
636
692
  };
637
693
  }
694
+ const transcriptListStartedAt = Date.now();
638
695
  const metas = listTranscripts(project.encoded, project.cwd);
696
+ metrics.transcriptListMs = Date.now() - transcriptListStartedAt;
639
697
  const selfNames = await getAgentSelfNames();
640
698
  const transcripts = metas.map(m => {
641
699
  const bind = bindMap.get(m.id);
700
+ metrics.totalMs = Date.now() - startedAt;
642
701
  return {
643
702
  ...m,
644
703
  sourceType: classifyTranscriptSource(bind, isCodexCliCandidate(m)),
@@ -663,9 +722,13 @@ async function buildSnapshot(params) {
663
722
  transcripts,
664
723
  turns: [],
665
724
  sessionId: null,
725
+ metrics,
666
726
  };
667
727
  }
668
- const detail = readTranscriptFile(sessionId, project.cwd);
728
+ const detailStartedAt = Date.now();
729
+ const detail = readTranscriptFile(sessionId, project.cwd, metrics);
730
+ metrics.detailMs = Date.now() - detailStartedAt;
731
+ const page = windowTranscriptTurns(detail.turns, params.turnLimit, params.turnBefore);
669
732
  const bind = bindMap.get(sessionId);
670
733
  const transcript = metas.find(meta => meta.id === sessionId);
671
734
  const header = {
@@ -689,14 +752,17 @@ async function buildSnapshot(params) {
689
752
  boundSelf: resolveSessionBindSelfName(bind, selfNames),
690
753
  online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
691
754
  };
755
+ metrics.totalMs = Date.now() - startedAt;
692
756
  return {
693
757
  baseagent: 'codex',
694
758
  projects: projList,
695
759
  project: project.encoded,
696
760
  transcripts,
697
- turns: detail.turns,
761
+ turns: previewLargeToolResults(page.turns, page.turnPage.start),
762
+ turnPage: page.turnPage,
698
763
  sessionId,
699
764
  header,
765
+ metrics,
700
766
  };
701
767
  }
702
768
  // ── Export ──
@@ -16,6 +16,7 @@ import { resolvePaths, ccProjectsDir } from '../paths.js';
16
16
  import { encodePath } from '../fs-utils.js';
17
17
  import { snapshotCodex, subscribeCodex, setDebugLog as setCodexDebugLog } from './session-codex.js';
18
18
  import { getAgentSelfNames } from './msg.js';
19
+ import { parseTranscriptResultKey, previewLargeToolResults, windowTranscriptTurns } from './transcript-window.js';
19
20
  import { buildSessionBindMap, classifyTranscriptSource, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
20
21
  let _dlog = null;
21
22
  export function setDebugLog(log) {
@@ -29,6 +30,8 @@ function dlog(line) { if (_dlog)
29
30
  catch { } }
30
31
  const CACHE_VERSION = 5;
31
32
  const _metaCache = new Map();
33
+ const _detailCache = new Map();
34
+ const MAX_DETAIL_CACHE_ENTRIES = 6;
32
35
  function cacheDir(encoded) {
33
36
  const dataDir = resolvePaths().dataDir;
34
37
  const currentRoot = path.join(dataDir, 'ecweb-cache');
@@ -60,6 +63,16 @@ function writeDiskCache(encoded, id, mtime, size, meta) {
60
63
  }
61
64
  catch { }
62
65
  }
66
+ function cacheTranscriptDetail(file, mtime, size, detail) {
67
+ _detailCache.delete(file);
68
+ _detailCache.set(file, { mtime, size, detail });
69
+ while (_detailCache.size > MAX_DETAIL_CACHE_ENTRIES) {
70
+ const oldest = _detailCache.keys().next().value;
71
+ if (!oldest)
72
+ break;
73
+ _detailCache.delete(oldest);
74
+ }
75
+ }
63
76
  function pruneCacheFiles(encoded, validIds) {
64
77
  const dir = cacheDir(encoded);
65
78
  try {
@@ -486,8 +499,23 @@ function pricingFor(model) {
486
499
  return PRICING['claude-haiku-4-5-20251001'];
487
500
  return PRICING['claude-opus-4-8'];
488
501
  }
489
- function readTranscriptFile(file) {
502
+ function readTranscriptFile(file, readStats) {
490
503
  const empty = { turns: [], totalTurns: 0, userMsgs: 0, totalMsgs: 0, counts: { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 }, inputTokens: 0, outputTokens: 0, contextTokens: 0, costUsd: 0, model: '', gitBranch: '', version: '', title: '', cwd: '' };
504
+ let stat;
505
+ try {
506
+ stat = fs.statSync(file);
507
+ }
508
+ catch {
509
+ return empty;
510
+ }
511
+ const cached = _detailCache.get(file);
512
+ if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
513
+ _detailCache.delete(file);
514
+ _detailCache.set(file, cached);
515
+ if (readStats)
516
+ readStats.detailCacheHit = true;
517
+ return cached.detail;
518
+ }
491
519
  let raw;
492
520
  try {
493
521
  raw = fs.readFileSync(file, 'utf-8');
@@ -575,20 +603,60 @@ function readTranscriptFile(file) {
575
603
  else {
576
604
  category = hasToolUse ? 'tool_call' : 'model_output';
577
605
  }
606
+ // Claude can emit assistant events that contain usage metadata but no visible blocks.
607
+ // They are not useful transcript entries and previously rendered as empty model output.
608
+ if (o.type === 'assistant' && blocks.length === 0)
609
+ continue;
578
610
  counts[category === 'user_input' ? 'userInput' : category === 'model_output' ? 'modelOutput' : category === 'tool_call' ? 'toolCall' : 'toolResult']++;
579
611
  turns.push({ role: o.type, ts: o.timestamp ? Date.parse(o.timestamp) : 0, uuid: o.uuid, category, blocks });
580
612
  }
581
613
  }
582
- const shown = turns.length > 500 ? turns.slice(-500) : turns;
583
- return { turns: shown, totalTurns: turns.length, userMsgs, totalMsgs, counts, inputTokens: inTok, outputTokens: outTok, contextTokens, costUsd, model, gitBranch: branch, version, title, cwd };
614
+ const detail = { turns, totalTurns: turns.length, userMsgs, totalMsgs, counts, inputTokens: inTok, outputTokens: outTok, contextTokens, costUsd, model, gitBranch: branch, version, title, cwd };
615
+ cacheTranscriptDetail(file, stat.mtimeMs, stat.size, detail);
616
+ return detail;
617
+ }
618
+ export function readClaudeSessionResult(params) {
619
+ const projects = listProjects();
620
+ const project = resolveProject(params, projects);
621
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
622
+ const key = parseTranscriptResultKey(params.resultKey);
623
+ if (!project || !sessionId || !key || !/^[A-Za-z0-9-]+$/.test(sessionId))
624
+ return null;
625
+ const file = path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`);
626
+ if (!file.startsWith(`${path.join(ccProjectsDir(), project.encoded)}${path.sep}`))
627
+ return null;
628
+ const detail = readTranscriptFile(file);
629
+ const bind = buildBindMap().get(sessionId);
630
+ const taskTurns = bind?.evolSessionId ? readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
631
+ const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
632
+ const block = turns[key[0]]?.blocks?.[key[1]];
633
+ return block?.kind === 'tool_result' && typeof block.text === 'string' ? block.text : null;
584
634
  }
585
635
  async function buildSnapshot(params) {
636
+ const startedAt = Date.now();
637
+ const metrics = {
638
+ baseagent: 'claude',
639
+ projectMs: 0,
640
+ bindingsMs: 0,
641
+ transcriptListMs: 0,
642
+ detailMs: 0,
643
+ totalMs: 0,
644
+ detailCacheHit: false,
645
+ };
646
+ const projectStartedAt = Date.now();
586
647
  const projects = listProjects();
648
+ metrics.projectMs = Date.now() - projectStartedAt;
649
+ const bindingsStartedAt = Date.now();
587
650
  const bindMap = buildBindMap();
651
+ metrics.bindingsMs = Date.now() - bindingsStartedAt;
588
652
  const project = resolveProject(params, projects);
589
- if (!project)
590
- return { baseagent: 'claude', projects: [], project: null, transcripts: [], turns: [], sessionId: null };
653
+ if (!project) {
654
+ metrics.totalMs = Date.now() - startedAt;
655
+ return { baseagent: 'claude', projects: [], project: null, transcripts: [], turns: [], sessionId: null, metrics };
656
+ }
657
+ const transcriptListStartedAt = Date.now();
591
658
  const metas = listTranscripts(project.encoded);
659
+ metrics.transcriptListMs = Date.now() - transcriptListStartedAt;
592
660
  const selfNames = await getAgentSelfNames();
593
661
  const transcripts = metas.map(m => {
594
662
  const bind = bindMap.get(m.id);
@@ -603,17 +671,22 @@ async function buildSnapshot(params) {
603
671
  });
604
672
  const projList = projects.map(p => ({ encoded: p.encoded, label: p.label, cwd: p.cwd, count: p.count }));
605
673
  const sessionId = params.sessionId || null;
606
- if (!sessionId)
607
- return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: [], sessionId: null };
608
- const detail = readTranscriptFile(path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`));
674
+ if (!sessionId) {
675
+ metrics.totalMs = Date.now() - startedAt;
676
+ return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: [], sessionId: null, metrics };
677
+ }
678
+ const detailStartedAt = Date.now();
679
+ const detail = readTranscriptFile(path.join(ccProjectsDir(), project.encoded, `${sessionId}.jsonl`), metrics);
680
+ metrics.detailMs = Date.now() - detailStartedAt;
609
681
  const bind = bindMap.get(sessionId);
610
682
  const taskTurns = bind?.evolSessionId ? readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
611
683
  const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
612
- const shownTurns = turns.length > 500 ? turns.slice(-500) : turns;
684
+ const page = windowTranscriptTurns(turns, params.turnLimit, params.turnBefore);
613
685
  const counts = { ...detail.counts, subtask: taskTurns.length };
614
686
  const transcript = metas.find(meta => meta.id === sessionId);
615
687
  const header = { sessionId, title: detail.title, model: detail.model, gitBranch: detail.gitBranch, version: detail.version, cwd: detail.cwd || project.cwd, totalTurns: detail.totalTurns, userMsgs: detail.userMsgs, totalMsgs: detail.totalMsgs, counts, inputTokens: detail.inputTokens, outputTokens: detail.outputTokens, contextTokens: detail.contextTokens, costUsd: detail.costUsd, sourceType: classifyTranscriptSource(bind, transcript?.cliCandidate === true), bound: !!bind, boundChannel: bind?.channelType ?? null, boundSelf: resolveSessionBindSelfName(bind, selfNames), online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false };
616
- return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: shownTurns, sessionId, header };
688
+ metrics.totalMs = Date.now() - startedAt;
689
+ return { baseagent: 'claude', projects: projList, project: project.encoded, transcripts, turns: previewLargeToolResults(page.turns, page.turnPage.start), turnPage: page.turnPage, sessionId, header, metrics };
617
690
  }
618
691
  export const sessionSource = {
619
692
  kind: 'session',
@@ -634,7 +707,7 @@ export const sessionSource = {
634
707
  const fire = () => {
635
708
  if (debounce)
636
709
  clearTimeout(debounce);
637
- debounce = setTimeout(() => { void buildSnapshot(params).then(push).catch(() => { }); }, 150);
710
+ debounce = setTimeout(() => { void buildSnapshot(params).then(push).catch(() => { }); }, 300);
638
711
  };
639
712
  const projects = listProjects();
640
713
  const project = resolveProject(params, projects);