ec-web 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,731 @@
1
+ /**
2
+ * Codex 会话数据源 — Codex thread 历史展示。
3
+ *
4
+ * 数据源:
5
+ * - ~/.codex/state_*.sqlite(元数据索引)
6
+ * - ~/.codex/sessions/YYYY/MM/DD/*.jsonl(rollout 文件)
7
+ *
8
+ * 与 Claude session source 接口对齐,返回相同的数据结构。
9
+ */
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import os from 'os';
13
+ import { createRequire } from 'module';
14
+ import { resolvePaths } from '../paths.js';
15
+ import { encodePath, scanChatDirs, readJsonFile } from '../fs-utils.js';
16
+ const requireFromHere = createRequire(import.meta.url);
17
+ let _dlog = null;
18
+ export function setDebugLog(log) { _dlog = log; }
19
+ function dlog(line) { if (_dlog)
20
+ try {
21
+ _dlog(line);
22
+ }
23
+ catch { } }
24
+ // ── SQLite 数据库访问 ──
25
+ let sqliteModule; // undefined = not tried, null = unavailable
26
+ function loadSqlite() {
27
+ if (sqliteModule !== undefined)
28
+ return sqliteModule;
29
+ try {
30
+ sqliteModule = requireFromHere('node:sqlite');
31
+ }
32
+ catch {
33
+ sqliteModule = null;
34
+ }
35
+ return sqliteModule;
36
+ }
37
+ function resolveStateDbPath() {
38
+ const codexHome = path.join(os.homedir(), '.codex');
39
+ if (!fs.existsSync(codexHome))
40
+ return null;
41
+ try {
42
+ const files = fs.readdirSync(codexHome)
43
+ .filter(f => /^state_\d+\.sqlite$/.test(f))
44
+ .sort((a, b) => {
45
+ const va = parseInt(a.match(/state_(\d+)/)?.[1] || '0');
46
+ const vb = parseInt(b.match(/state_(\d+)/)?.[1] || '0');
47
+ return vb - va;
48
+ });
49
+ return files.length > 0 ? path.join(codexHome, files[0]) : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ let _db = null;
56
+ let _dbInitialized = false;
57
+ function getDb() {
58
+ if (_dbInitialized)
59
+ return _db;
60
+ _dbInitialized = true;
61
+ const sqlite = loadSqlite();
62
+ if (!sqlite)
63
+ return null;
64
+ const dbPath = resolveStateDbPath();
65
+ if (!dbPath)
66
+ return null;
67
+ try {
68
+ _db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
69
+ dlog(`[codex] Opened state DB: ${dbPath}`);
70
+ }
71
+ catch (error) {
72
+ dlog(`[codex] Failed to open state DB: ${dbPath}`);
73
+ _db = null;
74
+ }
75
+ return _db;
76
+ }
77
+ // ── 项目列表 ──
78
+ function listProjects() {
79
+ const db = getDb();
80
+ if (!db)
81
+ return [];
82
+ try {
83
+ const rows = db.prepare(`
84
+ SELECT cwd, COUNT(*) as count, MAX(updated_at) as lastActivity
85
+ FROM threads
86
+ WHERE archived = 0
87
+ GROUP BY cwd
88
+ ORDER BY lastActivity DESC
89
+ `).all();
90
+ const projects = rows.map(r => {
91
+ const cwd = r.cwd || '';
92
+ const label = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || 'unknown';
93
+ return {
94
+ encoded: encodePath(cwd),
95
+ cwd,
96
+ label,
97
+ count: r.count || 0,
98
+ lastActivity: (r.lastActivity || 0) * 1000, // Codex uses Unix timestamp (seconds)
99
+ };
100
+ });
101
+ pruneCacheProjects(new Set(projects.map(project => project.encoded)));
102
+ return projects;
103
+ }
104
+ catch (error) {
105
+ dlog(`[codex] listProjects failed: ${error}`);
106
+ return [];
107
+ }
108
+ }
109
+ // ── 会话列表 ──
110
+ const CACHE_VERSION = 1;
111
+ const _metaCache = new Map();
112
+ function cacheDir(encoded) {
113
+ const dataDir = resolvePaths().dataDir;
114
+ return path.join(dataDir, 'ecweb-cache', 'codex', encoded);
115
+ }
116
+ function readDiskCache(encoded, id, mtime, size) {
117
+ try {
118
+ const rec = JSON.parse(fs.readFileSync(path.join(cacheDir(encoded), `${id}.json`), 'utf-8'));
119
+ if (rec.v === CACHE_VERSION && rec.mtime === mtime && rec.size === size && rec.meta)
120
+ return rec.meta;
121
+ }
122
+ catch { }
123
+ return null;
124
+ }
125
+ function writeDiskCache(encoded, id, mtime, size, meta) {
126
+ try {
127
+ const dir = cacheDir(encoded);
128
+ fs.mkdirSync(dir, { recursive: true });
129
+ const tmp = path.join(dir, `${id}.json.tmp`);
130
+ fs.writeFileSync(tmp, JSON.stringify({ v: CACHE_VERSION, mtime, size, meta }));
131
+ fs.renameSync(tmp, path.join(dir, `${id}.json`));
132
+ }
133
+ catch { }
134
+ }
135
+ function pruneCacheFiles(encoded, validIds) {
136
+ const dir = cacheDir(encoded);
137
+ try {
138
+ for (const name of fs.readdirSync(dir)) {
139
+ const match = name.match(/^(.+)\.json(?:\.tmp)?$/);
140
+ if (!match || validIds.has(match[1]))
141
+ continue;
142
+ try {
143
+ fs.unlinkSync(path.join(dir, name));
144
+ }
145
+ catch { }
146
+ }
147
+ if (fs.readdirSync(dir).length === 0)
148
+ fs.rmdirSync(dir);
149
+ }
150
+ catch { }
151
+ }
152
+ function pruneCacheProjects(validProjects) {
153
+ const root = path.join(resolvePaths().dataDir, 'ecweb-cache', 'codex');
154
+ try {
155
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
156
+ if (!entry.isDirectory() || validProjects.has(entry.name))
157
+ continue;
158
+ try {
159
+ fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
160
+ }
161
+ catch { }
162
+ }
163
+ }
164
+ catch { }
165
+ }
166
+ function extractMetaFromRollout(file, id) {
167
+ let raw = '';
168
+ try {
169
+ raw = fs.readFileSync(file, 'utf-8');
170
+ }
171
+ catch { }
172
+ let title = '', firstUser = '', gitBranch = '', version = '', userMsgs = 0, totalMsgs = 0;
173
+ const lines = raw.split('\n');
174
+ for (const line of lines) {
175
+ if (!line.trim())
176
+ continue;
177
+ try {
178
+ const event = JSON.parse(line);
179
+ // 提取 cwd 和 version
180
+ if (event.type === 'session_meta' && event.payload) {
181
+ if (!gitBranch && event.payload.cwd)
182
+ gitBranch = event.payload.cwd;
183
+ if (!version && event.payload.cli_version)
184
+ version = event.payload.cli_version;
185
+ }
186
+ // 提取 title(从第一个 user_message)
187
+ if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
188
+ totalMsgs++;
189
+ userMsgs++;
190
+ if (!firstUser) {
191
+ const text = event.payload.message.trim().replace(/\s+/g, ' ');
192
+ firstUser = text.substring(0, 120);
193
+ }
194
+ }
195
+ // 统计 assistant 消息
196
+ if (event.type === 'event_msg' && event.payload?.type === 'assistant_message') {
197
+ totalMsgs++;
198
+ }
199
+ }
200
+ catch { }
201
+ }
202
+ let stat;
203
+ try {
204
+ stat = fs.statSync(file);
205
+ }
206
+ catch {
207
+ stat = { size: 0, mtimeMs: 0 };
208
+ }
209
+ return {
210
+ id,
211
+ title: title || firstUser.substring(0, 50) || 'Untitled',
212
+ firstUser,
213
+ gitBranch,
214
+ version,
215
+ userMsgs,
216
+ totalMsgs,
217
+ sizeKB: Math.round(stat.size / 1024),
218
+ lastActivity: stat.mtimeMs,
219
+ };
220
+ }
221
+ function listTranscripts(encoded, cwd) {
222
+ const db = getDb();
223
+ if (!db)
224
+ return [];
225
+ try {
226
+ const rows = db.prepare(`
227
+ SELECT id, title, first_user_message, updated_at, rollout_path
228
+ FROM threads
229
+ WHERE cwd = ? AND archived = 0
230
+ ORDER BY updated_at DESC
231
+ `).all(cwd);
232
+ const out = [];
233
+ let parsed = 0, hitMem = 0, hitDisk = 0;
234
+ pruneCacheFiles(encoded, new Set(rows.map(row => String(row.id))));
235
+ for (const row of rows) {
236
+ const id = row.id;
237
+ const rolloutPath = row.rollout_path;
238
+ if (!rolloutPath || !fs.existsSync(rolloutPath)) {
239
+ // Fallback: 从 DB 直接构造 meta
240
+ const firstUser = (row.first_user_message || '').trim().replace(/\s+/g, ' ').substring(0, 120);
241
+ out.push({
242
+ id,
243
+ title: row.title || firstUser.substring(0, 50) || 'Untitled',
244
+ firstUser,
245
+ gitBranch: cwd,
246
+ version: '',
247
+ userMsgs: 0,
248
+ totalMsgs: 0,
249
+ sizeKB: 0,
250
+ lastActivity: (row.updated_at || 0) * 1000,
251
+ });
252
+ continue;
253
+ }
254
+ let stat;
255
+ try {
256
+ stat = fs.statSync(rolloutPath);
257
+ }
258
+ catch {
259
+ continue;
260
+ }
261
+ const mtime = stat.mtimeMs;
262
+ const mem = _metaCache.get(rolloutPath);
263
+ if (mem && mem.mtime === mtime) {
264
+ out.push(mem.meta);
265
+ hitMem++;
266
+ continue;
267
+ }
268
+ const disk = readDiskCache(encoded, id, mtime, stat.size);
269
+ if (disk) {
270
+ _metaCache.set(rolloutPath, { mtime, meta: disk });
271
+ out.push(disk);
272
+ hitDisk++;
273
+ continue;
274
+ }
275
+ const meta = extractMetaFromRollout(rolloutPath, id);
276
+ _metaCache.set(rolloutPath, { mtime, meta });
277
+ writeDiskCache(encoded, id, mtime, stat.size, meta);
278
+ out.push(meta);
279
+ parsed++;
280
+ }
281
+ dlog(`[codex] listTranscripts ${cwd.slice(-20)}: ${rows.length} threads (mem=${hitMem} disk=${hitDisk} parsed=${parsed})`);
282
+ return out;
283
+ }
284
+ catch (error) {
285
+ dlog(`[codex] listTranscripts failed: ${error}`);
286
+ return [];
287
+ }
288
+ }
289
+ // ── 绑定关系 ──
290
+ 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;
310
+ }
311
+ // ── 项目解析 ──
312
+ function resolveProject(params, projects) {
313
+ if (params.project) {
314
+ const found = projects.find(p => p.encoded === params.project);
315
+ if (found) {
316
+ dlog(`[codex] resolveProject: matched param project=${params.project.slice(-24)}`);
317
+ return found;
318
+ }
319
+ dlog(`[codex] resolveProject: param project=${params.project.slice(-24)} NOT in ${projects.length} projects → falling back`);
320
+ }
321
+ const curEncoded = encodePath(process.cwd());
322
+ const cur = projects.find(p => p.encoded === curEncoded);
323
+ if (cur) {
324
+ dlog(`[codex] resolveProject: default to cwd project=${curEncoded.slice(-24)}`);
325
+ return cur;
326
+ }
327
+ dlog(`[codex] resolveProject: cwd project=${curEncoded.slice(-24)} not found → using first=${projects[0]?.encoded.slice(-24)}`);
328
+ return projects[0] || null;
329
+ }
330
+ // ── 会话详情 ──
331
+ // OpenAI 定价表(2026-06 更新)
332
+ const PRICING = {
333
+ // [input, cache_write, cache_read, output] per 1M tokens
334
+ 'gpt-5.5': [5, 0.5, 0.5, 30],
335
+ 'gpt-5.5-pro': [30, 30, 30, 180],
336
+ 'gpt-5.4': [2.5, 0.25, 0.25, 15],
337
+ 'gpt-5.4-mini': [0.75, 0.075, 0.075, 3.75],
338
+ 'gpt-5.4-nano': [0.25, 0.025, 0.025, 1.25],
339
+ 'gpt-5.2': [1.75, 0.175, 0.175, 14],
340
+ 'gpt-4.1': [2.50, 0.625, 0.125, 10],
341
+ 'gpt-4.1-mini': [0.40, 0.10, 0.10, 1.60],
342
+ 'gpt-4.1-nano': [0.10, 0.025, 0.025, 0.40],
343
+ 'gpt-4o': [2.50, 1.25, 0.25, 10],
344
+ 'gpt-4o-mini': [0.15, 0.075, 0.075, 0.60],
345
+ 'o3': [2.00, 2.00, 2.00, 8.00],
346
+ 'o3-pro': [20.00, 20.00, 20.00, 80.00],
347
+ 'o4-mini': [1.10, 1.10, 1.10, 4.40],
348
+ };
349
+ function pricingFor(model) {
350
+ for (const key of Object.keys(PRICING)) {
351
+ if (model.startsWith(key))
352
+ return PRICING[key];
353
+ }
354
+ // 默认按 gpt-5.4 计算
355
+ return PRICING['gpt-5.4'];
356
+ }
357
+ function parseToolInput(value, rawKey) {
358
+ if (value == null || value === '')
359
+ return { input: {}, inputStr: '' };
360
+ if (typeof value !== 'string') {
361
+ const input = value && typeof value === 'object' && !Array.isArray(value)
362
+ ? value
363
+ : { [rawKey]: value };
364
+ try {
365
+ return { input, inputStr: JSON.stringify(value, null, 2) };
366
+ }
367
+ catch {
368
+ return { input, inputStr: String(value) };
369
+ }
370
+ }
371
+ try {
372
+ const parsed = JSON.parse(value);
373
+ const input = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
374
+ ? parsed
375
+ : { [rawKey]: parsed };
376
+ return { input, inputStr: JSON.stringify(parsed, null, 2) };
377
+ }
378
+ catch {
379
+ return { input: { [rawKey]: value }, inputStr: value };
380
+ }
381
+ }
382
+ function toolOutputText(output) {
383
+ if (typeof output === 'string')
384
+ return output;
385
+ if (Array.isArray(output)) {
386
+ return output.map(item => {
387
+ if (typeof item === 'string')
388
+ return item;
389
+ if (item && typeof item === 'object' && typeof item.text === 'string')
390
+ return item.text;
391
+ try {
392
+ return JSON.stringify(item, null, 2);
393
+ }
394
+ catch {
395
+ return String(item);
396
+ }
397
+ }).join('\n');
398
+ }
399
+ if (output && typeof output === 'object' && typeof output.text === 'string')
400
+ return output.text;
401
+ if (output == null)
402
+ return '';
403
+ try {
404
+ return JSON.stringify(output, null, 2);
405
+ }
406
+ catch {
407
+ return String(output);
408
+ }
409
+ }
410
+ export function parseCodexToolEvent(event) {
411
+ if (!event || (event.type !== 'event_msg' && event.type !== 'response_item') || !event.payload)
412
+ return null;
413
+ const payload = event.payload;
414
+ const ts = event.timestamp ? Date.parse(event.timestamp) : 0;
415
+ const callId = String(payload.call_id || payload.id || '');
416
+ if (payload.type === 'function_call' || payload.type === 'custom_tool_call') {
417
+ const custom = payload.type === 'custom_tool_call';
418
+ const parsed = parseToolInput(custom ? payload.input : payload.arguments, custom ? 'input' : 'arguments');
419
+ return {
420
+ kind: 'call',
421
+ callId,
422
+ turn: {
423
+ role: 'assistant',
424
+ ts,
425
+ uuid: callId,
426
+ category: 'tool_call',
427
+ blocks: [{
428
+ kind: 'tool_use',
429
+ name: payload.name || '',
430
+ input: parsed.input,
431
+ inputStr: parsed.inputStr,
432
+ }],
433
+ },
434
+ };
435
+ }
436
+ if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') {
437
+ return {
438
+ kind: 'result',
439
+ callId,
440
+ turn: {
441
+ role: 'user',
442
+ ts,
443
+ uuid: callId,
444
+ category: 'tool_result',
445
+ blocks: [{
446
+ kind: 'tool_result',
447
+ text: toolOutputText(payload.output),
448
+ isError: payload.is_error === true || payload.status === 'failed',
449
+ }],
450
+ },
451
+ };
452
+ }
453
+ return null;
454
+ }
455
+ function readTranscriptFile(threadId, cwd) {
456
+ const empty = {
457
+ turns: [],
458
+ totalTurns: 0,
459
+ userMsgs: 0,
460
+ totalMsgs: 0,
461
+ counts: { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 },
462
+ inputTokens: 0,
463
+ outputTokens: 0,
464
+ contextTokens: 0,
465
+ costUsd: 0,
466
+ model: '',
467
+ gitBranch: cwd,
468
+ version: '',
469
+ title: '',
470
+ cwd,
471
+ };
472
+ const db = getDb();
473
+ if (!db)
474
+ return empty;
475
+ let rolloutPath;
476
+ try {
477
+ const row = db.prepare('SELECT rollout_path, title FROM threads WHERE id = ?').get(threadId);
478
+ if (!row || !row.rollout_path)
479
+ return empty;
480
+ rolloutPath = row.rollout_path;
481
+ empty.title = row.title || '';
482
+ }
483
+ catch {
484
+ return empty;
485
+ }
486
+ if (!fs.existsSync(rolloutPath))
487
+ return empty;
488
+ let raw;
489
+ try {
490
+ raw = fs.readFileSync(rolloutPath, 'utf-8');
491
+ }
492
+ catch {
493
+ return empty;
494
+ }
495
+ const turns = [];
496
+ const counts = { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 };
497
+ let inTok = 0, outTok = 0, model = '', version = '', userMsgs = 0, totalMsgs = 0, contextTokens = 0, costUsd = 0;
498
+ let lastUsageKey = '';
499
+ const seenToolCalls = new Set();
500
+ const seenToolResults = new Set();
501
+ for (const line of raw.split('\n')) {
502
+ if (!line.trim())
503
+ continue;
504
+ let event;
505
+ try {
506
+ event = JSON.parse(line);
507
+ }
508
+ catch {
509
+ continue;
510
+ }
511
+ // 提取 session_meta
512
+ if (event.type === 'session_meta' && event.payload) {
513
+ if (!version && event.payload.cli_version)
514
+ version = event.payload.cli_version;
515
+ }
516
+ // 提取 model 信息(从 payload.model)
517
+ if (event.payload?.model && !model) {
518
+ model = event.payload.model;
519
+ }
520
+ // 处理 token_count 事件(Codex 的 usage 信息)
521
+ if (event.type === 'event_msg' && event.payload?.type === 'token_count' && event.payload.info) {
522
+ const u = event.payload.info.last_token_usage;
523
+ if (u) {
524
+ const inp = u.input_tokens || 0;
525
+ const cached = u.cached_input_tokens || 0;
526
+ const out = u.output_tokens || 0;
527
+ // 避免重复计算同一个 usage
528
+ const key = `${inp},${cached},${out}`;
529
+ if (key !== lastUsageKey) {
530
+ lastUsageKey = key;
531
+ inTok += inp;
532
+ outTok += out;
533
+ contextTokens = inp + cached;
534
+ // 计算费用
535
+ const [pi, pcw, pcr, po] = pricingFor(model || 'gpt-5.4');
536
+ // Codex 的 cached_input_tokens 对应 cache_read
537
+ costUsd += (inp * pi + cached * pcr + out * po) / 1_000_000;
538
+ }
539
+ }
540
+ }
541
+ // 处理消息事件
542
+ if (event.type === 'event_msg' && event.payload) {
543
+ const payload = event.payload;
544
+ if (payload.type === 'user_message' && payload.message) {
545
+ totalMsgs++;
546
+ userMsgs++;
547
+ counts.userInput++;
548
+ const text = payload.message.trim();
549
+ turns.push({
550
+ role: 'user',
551
+ ts: event.timestamp ? Date.parse(event.timestamp) : 0,
552
+ uuid: payload.id || '',
553
+ category: 'user_input',
554
+ blocks: [{ kind: 'text', text }],
555
+ });
556
+ }
557
+ else if (payload.type === 'agent_message' && payload.message) {
558
+ totalMsgs++;
559
+ counts.modelOutput++;
560
+ const blocks = [];
561
+ // 提取 message
562
+ if (payload.message) {
563
+ blocks.push({ kind: 'text', text: payload.message });
564
+ }
565
+ turns.push({
566
+ role: 'assistant',
567
+ ts: event.timestamp ? Date.parse(event.timestamp) : 0,
568
+ uuid: payload.id || '',
569
+ category: 'model_output',
570
+ blocks,
571
+ });
572
+ }
573
+ else if (payload.type === 'reasoning' && payload.message) {
574
+ // Codex reasoning 对应 Claude 的 thinking
575
+ const lastTurn = turns[turns.length - 1];
576
+ if (lastTurn && lastTurn.role === 'assistant') {
577
+ lastTurn.blocks.unshift({ kind: 'thinking', text: payload.message });
578
+ }
579
+ }
580
+ }
581
+ const toolEvent = parseCodexToolEvent(event);
582
+ if (toolEvent) {
583
+ const seen = toolEvent.kind === 'call' ? seenToolCalls : seenToolResults;
584
+ if (!toolEvent.callId || !seen.has(toolEvent.callId)) {
585
+ if (toolEvent.callId)
586
+ seen.add(toolEvent.callId);
587
+ if (toolEvent.kind === 'call')
588
+ counts.toolCall++;
589
+ else
590
+ counts.toolResult++;
591
+ turns.push(toolEvent.turn);
592
+ }
593
+ }
594
+ }
595
+ const shown = turns.length > 500 ? turns.slice(-500) : turns;
596
+ return {
597
+ turns: shown,
598
+ totalTurns: turns.length,
599
+ userMsgs,
600
+ totalMsgs,
601
+ counts,
602
+ inputTokens: inTok,
603
+ outputTokens: outTok,
604
+ contextTokens,
605
+ costUsd,
606
+ model,
607
+ gitBranch: empty.gitBranch,
608
+ version,
609
+ title: empty.title,
610
+ cwd,
611
+ };
612
+ }
613
+ // ── Snapshot ──
614
+ function buildSnapshot(params) {
615
+ const projects = listProjects();
616
+ const bindMap = buildBindMap();
617
+ const project = resolveProject(params, projects);
618
+ if (!project) {
619
+ return {
620
+ baseagent: 'codex',
621
+ projects: [],
622
+ project: null,
623
+ transcripts: [],
624
+ turns: [],
625
+ sessionId: null,
626
+ };
627
+ }
628
+ const metas = listTranscripts(project.encoded, project.cwd);
629
+ const transcripts = metas.map(m => {
630
+ const bind = bindMap.get(m.id);
631
+ return {
632
+ ...m,
633
+ bound: !!bind,
634
+ boundChannel: bind?.channelType ?? null,
635
+ boundPeer: bind ? (bind.peerName || bind.channelId) : null,
636
+ online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
637
+ };
638
+ });
639
+ const projList = projects.map(p => ({
640
+ encoded: p.encoded,
641
+ label: p.label,
642
+ cwd: p.cwd,
643
+ count: p.count,
644
+ }));
645
+ const sessionId = params.sessionId || null;
646
+ if (!sessionId) {
647
+ return {
648
+ baseagent: 'codex',
649
+ projects: projList,
650
+ project: project.encoded,
651
+ transcripts,
652
+ turns: [],
653
+ sessionId: null,
654
+ };
655
+ }
656
+ const detail = readTranscriptFile(sessionId, project.cwd);
657
+ const bind = bindMap.get(sessionId);
658
+ const header = {
659
+ sessionId,
660
+ title: detail.title,
661
+ model: detail.model,
662
+ gitBranch: detail.gitBranch,
663
+ version: detail.version,
664
+ cwd: detail.cwd,
665
+ totalTurns: detail.totalTurns,
666
+ userMsgs: detail.userMsgs,
667
+ totalMsgs: detail.totalMsgs,
668
+ counts: detail.counts,
669
+ inputTokens: detail.inputTokens,
670
+ outputTokens: detail.outputTokens,
671
+ contextTokens: detail.contextTokens,
672
+ costUsd: detail.costUsd,
673
+ bound: !!bind,
674
+ boundChannel: bind?.channelType ?? null,
675
+ boundPeer: bind ? (bind.peerName || bind.channelId) : null,
676
+ online: bind ? (Date.now() - bind.updatedAt < 5 * 60 * 1000) : false,
677
+ };
678
+ return {
679
+ baseagent: 'codex',
680
+ projects: projList,
681
+ project: project.encoded,
682
+ transcripts,
683
+ turns: detail.turns,
684
+ sessionId,
685
+ header,
686
+ };
687
+ }
688
+ // ── Export ──
689
+ export async function snapshotCodex(params = {}) {
690
+ return buildSnapshot(params);
691
+ }
692
+ export function subscribeCodex(params, push) {
693
+ const p = resolvePaths();
694
+ let dbWatcher = null;
695
+ let sessionWatcher = null;
696
+ let debounce = null;
697
+ const fire = () => {
698
+ if (debounce)
699
+ clearTimeout(debounce);
700
+ debounce = setTimeout(() => {
701
+ try {
702
+ push(buildSnapshot(params));
703
+ }
704
+ catch { }
705
+ }, 150);
706
+ };
707
+ // 监听 state_*.sqlite 变化
708
+ const dbPath = resolveStateDbPath();
709
+ if (dbPath && fs.existsSync(dbPath)) {
710
+ try {
711
+ dbWatcher = fs.watch(dbPath, () => fire());
712
+ }
713
+ catch { }
714
+ }
715
+ // 监听 evolclaw sessionsDir 的 active.json 变化
716
+ try {
717
+ sessionWatcher = fs.watch(p.sessionsDir, { recursive: true }, (_evt, filename) => {
718
+ if (filename && String(filename).endsWith('active.json'))
719
+ fire();
720
+ });
721
+ }
722
+ catch { }
723
+ return () => {
724
+ if (dbWatcher)
725
+ dbWatcher.close();
726
+ if (sessionWatcher)
727
+ sessionWatcher.close();
728
+ if (debounce)
729
+ clearTimeout(debounce);
730
+ };
731
+ }