cx-viewer 1.0.0 → 1.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.
Files changed (70) hide show
  1. package/cli.js +2 -2
  2. package/dist/assets/App-CF-qZ3B6.js +41 -0
  3. package/dist/assets/{AppHeader-B3hrnZr-.css → AppHeader-BqUGBbBx.css} +2 -2
  4. package/dist/assets/{AppHeader.module-BMU2vyC4.js → AppHeader.module-BjoBBbCB.js} +194 -190
  5. package/dist/assets/Mobile-ByhBkyqb.js +10 -0
  6. package/dist/assets/{_baseUniq-CXWHShmB.js → _baseUniq-5XIuSb_1.js} +1 -1
  7. package/dist/assets/{arc-qbol0NJo.js → arc-V3mJhKFJ.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-COZebb1N.js → architectureDiagram-Q4EWVU46-22bjxOE1.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-COCgzPKy.js → blockDiagram-DXYQGD6D-Brshek1N.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-Bs5XvLEo.js → c4Diagram-AHTNJAMY-DQHIuxkd.js} +1 -1
  11. package/dist/assets/channel-SmGaOM6g.js +1 -0
  12. package/dist/assets/{chunk-4BX2VUAB-CyGriz9d.js → chunk-4BX2VUAB-BQE7pifU.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-DlT_q-fl.js → chunk-4TB4RGXK-DcZwv_PV.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-NHN99ZNC.js → chunk-55IACEB6-Gd_3-vV5.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-fK4f9Na9.js → chunk-EDXVE4YY-BbnYdg9M.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-DL-dQIq1.js → chunk-FMBD7UC4-NSh7z90L.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-6uruB4VY.js → chunk-OYMX7WX6-DdFwjHsc.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-CCWIqLO8.js → chunk-QZHKN3VN-C-1z3DA3.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-Cu84DMJo.js → chunk-YZCP3GAM-w_NiY6dL.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-Bu96zciS.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-Bu96zciS.js +1 -0
  22. package/dist/assets/clone-u2hQVO7u.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-Dqi4iU3-.js → cose-bilkent-S5V4N54A-bHlM-ydV.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-Dl-EJ9rb.js → dagre-KV5264BT-2cCXOuNw.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-BYtyzF0D.js → diagram-5BDNPKRD-C70P_Nd1.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BuETHFNU.js → diagram-G4DWMVQ6-C2JvwT8U.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-Z02oZp6I.js → diagram-MMDJMWI5-C9ygMSLV.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-BpGlKv8l.js → diagram-TYMM5635-x-SMPmqb.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-C4YngeQh.js → erDiagram-SMLLAGMA-BGIbL82W.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-BWddFffg.js → flowDiagram-DWJPFMVM-Dg0qwguE.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-Bg0jOD4m.js → ganttDiagram-T4ZO3ILL-XX5qCwKD.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Bz-3sTXZ.js → gitGraphDiagram-UUTBAWPF-DFil4bpA.js} +1 -1
  33. package/dist/assets/{graph-XiH8DkIu.js → graph-DJ6egHOd.js} +1 -1
  34. package/dist/assets/{index-BuUsWKGZ.js → index-DYIYWRaO.js} +2 -2
  35. package/dist/assets/{infoDiagram-42DDH7IO-DLo-lOCE.js → infoDiagram-42DDH7IO-yB7VRrpO.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BsIzT5eO.js → ishikawaDiagram-UXIWVN3A-DZ_lPco0.js} +1 -1
  37. package/dist/assets/{journeyDiagram-VCZTEJTY-C3bOkKqv.js → journeyDiagram-VCZTEJTY-B-FlPR2W.js} +1 -1
  38. package/dist/assets/{kanban-definition-6JOO6SKY-D-rJRNbp.js → kanban-definition-6JOO6SKY-mgJwVxKf.js} +1 -1
  39. package/dist/assets/{layout-Bqp8Hh1c.js → layout-BjzpJ8ZU.js} +1 -1
  40. package/dist/assets/{linear-BWRSipGb.js → linear-CqOq1rA7.js} +1 -1
  41. package/dist/assets/{mermaid.core-CJa7klhs.js → mermaid.core-CZyQhtml.js} +4 -4
  42. package/dist/assets/{min-CQ_VyzMd.js → min-B_tad8Qz.js} +1 -1
  43. package/dist/assets/{mindmap-definition-QFDTVHPH-Bnj7DDSx.js → mindmap-definition-QFDTVHPH-CgBeHk8K.js} +1 -1
  44. package/dist/assets/{pieDiagram-DEJITSTG-CC8a7Ylw.js → pieDiagram-DEJITSTG-CHENa0KJ.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-34T5L4WZ-CrmL1WUV.js → quadrantDiagram-34T5L4WZ-BUwZwCpc.js} +1 -1
  46. package/dist/assets/{requirementDiagram-MS252O5E-TfkX4u_6.js → requirementDiagram-MS252O5E-H7OaNjNn.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-XADWPNL6-DMwBbND7.js → sankeyDiagram-XADWPNL6-6UqaFrBf.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-FGHM5R23-CFghMYsm.js → sequenceDiagram-FGHM5R23-DBgpLSsr.js} +1 -1
  49. package/dist/assets/{stateDiagram-FHFEXIEX-1H6JIM7l.js → stateDiagram-FHFEXIEX-D_6TWlLl.js} +1 -1
  50. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Cg06jbzN.js +1 -0
  51. package/dist/assets/{timeline-definition-GMOUNBTQ-DnLMBlVa.js → timeline-definition-GMOUNBTQ-9_saaHvl.js} +1 -1
  52. package/dist/assets/{vennDiagram-DHZGUBPP-DoIkY_KL.js → vennDiagram-DHZGUBPP-DMUS1fiT.js} +1 -1
  53. package/dist/assets/{wardley-RL74JXVD-3n5XmOD5.js → wardley-RL74JXVD-Y-_Nnpu2.js} +1 -1
  54. package/dist/assets/{wardleyDiagram-NUSXRM2D-UMOq3ofC.js → wardleyDiagram-NUSXRM2D-DIh62HY7.js} +1 -1
  55. package/dist/assets/{xychartDiagram-5P7HB3ND-CNDuQ5Cg.js → xychartDiagram-5P7HB3ND-B_CLrnpt.js} +1 -1
  56. package/dist/index.html +1 -1
  57. package/lib/appserver-bridge.js +108 -9
  58. package/lib/log-management.js +3 -1
  59. package/lib/plugin-loader.js +126 -6
  60. package/lib/sdk-manager.js +55 -0
  61. package/package.json +1 -1
  62. package/pty-manager.js +27 -3
  63. package/server.js +301 -34
  64. package/dist/assets/App-BzrN42Qt.js +0 -41
  65. package/dist/assets/Mobile-C9wxQTXV.js +0 -10
  66. package/dist/assets/channel-DvLUZ3Xf.js +0 -1
  67. package/dist/assets/classDiagram-6PBFFD2Q-ByKhp2Tk.js +0 -1
  68. package/dist/assets/classDiagram-v2-HSJHXN6E-ByKhp2Tk.js +0 -1
  69. package/dist/assets/clone-DZHpE494.js +0 -1
  70. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CtXMaKcb.js +0 -1
package/server.js CHANGED
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { createServer as createHttpsServer } from 'node:https';
3
3
  import { createConnection } from 'node:net';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync } from 'node:fs';
5
+ import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync, appendFileSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { dirname, join, extname, resolve, basename } from 'node:path';
8
8
  import { homedir, platform, networkInterfaces } from 'node:os';
@@ -53,6 +53,31 @@ import { countLogEntries, streamRawEntriesAsync, readPagedEntries } from './lib/
53
53
 
54
54
  // 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
55
55
  function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
56
+ function readPreferences() {
57
+ let prefs = {};
58
+ try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
59
+ return prefs;
60
+ }
61
+ function writePreferences(prefs) {
62
+ const prefsFile = getPrefsFile();
63
+ const prefsDir = dirname(prefsFile);
64
+ if (!existsSync(prefsDir)) mkdirSync(prefsDir, { recursive: true });
65
+ writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
66
+ }
67
+ function normalizePluginFilename(name) {
68
+ return String(name || '').replace(/.*[/\\]/, '');
69
+ }
70
+ function removeDisabledPluginNames(names) {
71
+ const targetNames = Array.from(new Set((names || []).filter(Boolean)));
72
+ if (targetNames.length === 0) return false;
73
+ const prefs = readPreferences();
74
+ const disabledPlugins = Array.isArray(prefs.disabledPlugins) ? prefs.disabledPlugins : [];
75
+ const next = disabledPlugins.filter(name => !targetNames.includes(name));
76
+ if (next.length === disabledPlugins.length) return false;
77
+ prefs.disabledPlugins = next;
78
+ writePreferences(prefs);
79
+ return true;
80
+ }
56
81
 
57
82
  // 启动时一次性读取 ~/.codex/settings.json(不 watch)
58
83
  let codexSettings = {};
@@ -153,11 +178,73 @@ const ACCESS_TOKEN = randomBytes(16).toString('hex');
153
178
 
154
179
  let clients = [];
155
180
  let server;
181
+ let secondaryServer = null;
156
182
  let actualPort = 0;
183
+ let secondaryPort = 0;
157
184
  let serverProtocol = 'http';
185
+ let publicAccessProtocol = 'http';
186
+ let publicAccessPort = 0;
187
+ let pluginRoutes = [];
158
188
  // Stats Worker 实例
159
189
  let statsWorker = null;
160
190
 
191
+ function registerPluginRoute(method, path, handler) {
192
+ const normalizedMethod = String(method || 'GET').toUpperCase();
193
+ const normalizedPath = String(path || '').trim();
194
+ if (!normalizedPath.startsWith('/')) {
195
+ throw new Error('Plugin route path must start with "/"');
196
+ }
197
+ if (typeof handler !== 'function') {
198
+ throw new Error('Plugin route handler must be a function');
199
+ }
200
+ const nextRoute = { method: normalizedMethod, path: normalizedPath, handler };
201
+ const idx = pluginRoutes.findIndex(route => route.method === normalizedMethod && route.path === normalizedPath);
202
+ if (idx >= 0) pluginRoutes[idx] = nextRoute;
203
+ else pluginRoutes.push(nextRoute);
204
+ }
205
+
206
+ async function refreshPluginRuntime() {
207
+ pluginRoutes = [];
208
+ if (!server || !actualPort) return;
209
+ await runParallelHook('serverStarted', {
210
+ port: actualPort,
211
+ host: HOST,
212
+ url: `${serverProtocol}://127.0.0.1:${actualPort}`,
213
+ ip: getLocalIp(),
214
+ token: ACCESS_TOKEN,
215
+ protocol: serverProtocol,
216
+ httpServer: server,
217
+ registerRoute: registerPluginRoute,
218
+ });
219
+ }
220
+
221
+ function getPublicAccessUrl(ip = getLocalIp()) {
222
+ const protocol = publicAccessProtocol || serverProtocol;
223
+ const port = publicAccessPort || actualPort;
224
+ return `${protocol}://${ip}:${port}?token=${ACCESS_TOKEN}`;
225
+ }
226
+
227
+ function findAvailablePort(startPort) {
228
+ return new Promise((resolve) => {
229
+ function tryPort(port) {
230
+ if (port > MAX_PORT) {
231
+ resolve(null);
232
+ return;
233
+ }
234
+ const probe = createConnection({ host: '127.0.0.1', port });
235
+ probe.on('connect', () => {
236
+ probe.destroy();
237
+ tryPort(port + 1);
238
+ });
239
+ probe.on('error', () => {
240
+ probe.destroy();
241
+ resolve(port);
242
+ });
243
+ }
244
+ tryPort(startPort);
245
+ });
246
+ }
247
+
161
248
  function startStatsWorker() {
162
249
  try {
163
250
  statsWorker = new Worker(new URL('./lib/stats-worker.js', import.meta.url));
@@ -270,6 +357,21 @@ async function handleRequest(req, res) {
270
357
  }
271
358
  }
272
359
 
360
+ const pluginRoute = pluginRoutes.find(route => route.method === method && route.path === url);
361
+ if (pluginRoute) {
362
+ try {
363
+ await pluginRoute.handler(req, res, parsedUrl);
364
+ } catch (err) {
365
+ if (!res.headersSent) {
366
+ res.writeHead(500, { 'Content-Type': 'application/json' });
367
+ res.end(JSON.stringify({ error: err.message }));
368
+ } else {
369
+ try { res.end(); } catch {}
370
+ }
371
+ }
372
+ return;
373
+ }
374
+
273
375
  // OTLP HTTP 接收端点 — 接收 Codex 原生 OTel trace 数据
274
376
  if (url === '/v1/traces' && method === 'POST') {
275
377
  const buffers = [];
@@ -466,8 +568,7 @@ async function handleRequest(req, res) {
466
568
  }
467
569
 
468
570
  if (url === '/api/preferences' && method === 'GET') {
469
- let prefs = {};
470
- try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
571
+ let prefs = readPreferences();
471
572
  prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
472
573
  res.writeHead(200, { 'Content-Type': 'application/json' });
473
574
  res.end(JSON.stringify(prefs));
@@ -484,14 +585,9 @@ async function handleRequest(req, res) {
484
585
  if (incoming.logDir && typeof incoming.logDir === 'string') {
485
586
  setLogDir(incoming.logDir);
486
587
  }
487
- let prefs = {};
488
- try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
588
+ let prefs = readPreferences();
489
589
  Object.assign(prefs, incoming);
490
- // 确保目录存在
491
- const prefsFile = getPrefsFile();
492
- const prefsDir = dirname(prefsFile);
493
- if (!existsSync(prefsDir)) mkdirSync(prefsDir, { recursive: true });
494
- writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
590
+ writePreferences(prefs);
495
591
  // 主题切换时同步到 Codex Code CLI:发 /theme,监听输出验证结果,不对就再发一次
496
592
  if (incoming.themeColor && _writeToPty && _onPtyData) {
497
593
  const target = incoming.themeColor === 'light' ? 'light' : 'dark';
@@ -973,6 +1069,17 @@ async function handleRequest(req, res) {
973
1069
  return;
974
1070
  }
975
1071
 
1072
+ if (url === '/api/terminal-history' && method === 'GET') {
1073
+ const { getPtyState, getOutputBuffer, getOutputHistoryId } = await import('./pty-manager.js');
1074
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1075
+ res.end(JSON.stringify({
1076
+ ...getPtyState(),
1077
+ historyId: getOutputHistoryId(),
1078
+ data: getOutputBuffer() || '',
1079
+ }));
1080
+ return;
1081
+ }
1082
+
976
1083
  // 当前版本号
977
1084
  if (url === '/api/version-info' && method === 'GET') {
978
1085
  try {
@@ -2263,6 +2370,32 @@ async function handleRequest(req, res) {
2263
2370
  return;
2264
2371
  }
2265
2372
 
2373
+ if (url === '/api/plugins/module' && method === 'GET') {
2374
+ try {
2375
+ const file = normalizePluginFilename(parsedUrl.searchParams.get('file'));
2376
+ if (!file || (extname(file) !== '.js' && extname(file) !== '.mjs')) {
2377
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2378
+ res.end(JSON.stringify({ error: 'Invalid plugin filename' }));
2379
+ return;
2380
+ }
2381
+ const filePath = join(getPluginsDir(), file);
2382
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
2383
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2384
+ res.end(JSON.stringify({ error: 'Plugin file not found' }));
2385
+ return;
2386
+ }
2387
+ res.writeHead(200, {
2388
+ 'Content-Type': 'application/javascript; charset=utf-8',
2389
+ 'Cache-Control': 'no-store',
2390
+ });
2391
+ res.end(readFileSync(filePath, 'utf-8'));
2392
+ } catch (err) {
2393
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2394
+ res.end(JSON.stringify({ error: err.message }));
2395
+ }
2396
+ return;
2397
+ }
2398
+
2266
2399
  if (url === '/api/plugins' && method === 'DELETE') {
2267
2400
  const file = parsedUrl.searchParams.get('file');
2268
2401
  if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) {
@@ -2277,8 +2410,11 @@ async function handleRequest(req, res) {
2277
2410
  res.end(JSON.stringify({ error: 'File not found' }));
2278
2411
  return;
2279
2412
  }
2413
+ const targetPlugin = getPluginsInfo().find(p => p.file === file);
2280
2414
  unlinkSync(filePath);
2415
+ if (targetPlugin?.name) removeDisabledPluginNames([targetPlugin.name]);
2281
2416
  await loadPlugins();
2417
+ await refreshPluginRuntime();
2282
2418
  const plugins = getPluginsInfo();
2283
2419
  res.writeHead(200, { 'Content-Type': 'application/json' });
2284
2420
  res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
@@ -2292,6 +2428,7 @@ async function handleRequest(req, res) {
2292
2428
  if (url === '/api/plugins/reload' && method === 'POST') {
2293
2429
  try {
2294
2430
  await loadPlugins();
2431
+ await refreshPluginRuntime();
2295
2432
  const plugins = getPluginsInfo();
2296
2433
  res.writeHead(200, { 'Content-Type': 'application/json' });
2297
2434
  res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
@@ -2310,7 +2447,15 @@ async function handleRequest(req, res) {
2310
2447
  const { files: fileList } = JSON.parse(body);
2311
2448
  uploadPlugins(getPluginsDir(), fileList);
2312
2449
  await loadPlugins();
2313
- const plugins = getPluginsInfo();
2450
+ await refreshPluginRuntime();
2451
+ let plugins = getPluginsInfo();
2452
+ const uploadedFiles = new Set((fileList || []).map(file => normalizePluginFilename(file?.name)));
2453
+ const uploadedNames = plugins.filter(plugin => uploadedFiles.has(plugin.file)).map(plugin => plugin.name);
2454
+ if (removeDisabledPluginNames(uploadedNames)) {
2455
+ await loadPlugins();
2456
+ await refreshPluginRuntime();
2457
+ plugins = getPluginsInfo();
2458
+ }
2314
2459
  res.writeHead(200, { 'Content-Type': 'application/json' });
2315
2460
  res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
2316
2461
  } catch (err) {
@@ -2329,9 +2474,16 @@ async function handleRequest(req, res) {
2329
2474
  try {
2330
2475
  const { url: fileUrl } = JSON.parse(body);
2331
2476
  const extractScript = join(__dirname, 'lib', 'extract-plugin-name.mjs');
2332
- await installPluginFromUrl(getPluginsDir(), fileUrl, extractScript);
2477
+ const { filename } = await installPluginFromUrl(getPluginsDir(), fileUrl, extractScript);
2333
2478
  await loadPlugins();
2334
- const plugins = getPluginsInfo();
2479
+ await refreshPluginRuntime();
2480
+ let plugins = getPluginsInfo();
2481
+ const installedNames = plugins.filter(plugin => plugin.file === filename).map(plugin => plugin.name);
2482
+ if (removeDisabledPluginNames(installedNames)) {
2483
+ await loadPlugins();
2484
+ await refreshPluginRuntime();
2485
+ plugins = getPluginsInfo();
2486
+ }
2335
2487
  res.writeHead(200, { 'Content-Type': 'application/json' });
2336
2488
  res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
2337
2489
  } catch (err) {
@@ -2346,10 +2498,22 @@ async function handleRequest(req, res) {
2346
2498
  // 返回局域网访问地址
2347
2499
  if (url === '/api/local-url' && method === 'GET') {
2348
2500
  const localIp = getLocalIp();
2349
- const defaultUrl = `${serverProtocol}://${localIp}:${actualPort}?token=${ACCESS_TOKEN}`;
2350
- const hookResult = await runWaterfallHook('localUrl', { url: defaultUrl, ip: localIp, port: actualPort, token: ACCESS_TOKEN });
2501
+ const defaultUrl = getPublicAccessUrl(localIp);
2502
+ const hookResult = await runWaterfallHook('localUrl', {
2503
+ url: defaultUrl,
2504
+ ip: localIp,
2505
+ port: publicAccessPort || actualPort,
2506
+ token: ACCESS_TOKEN,
2507
+ protocol: publicAccessProtocol || serverProtocol,
2508
+ httpUrl: actualPort ? `http://${localIp}:${actualPort}?token=${ACCESS_TOKEN}` : null,
2509
+ httpsUrl: secondaryPort ? `https://${localIp}:${secondaryPort}?token=${ACCESS_TOKEN}` : null,
2510
+ });
2351
2511
  res.writeHead(200, { 'Content-Type': 'application/json' });
2352
- res.end(JSON.stringify({ url: hookResult.url }));
2512
+ res.end(JSON.stringify({
2513
+ url: hookResult.url,
2514
+ httpUrl: actualPort ? `http://${localIp}:${actualPort}?token=${ACCESS_TOKEN}` : null,
2515
+ httpsUrl: secondaryPort ? `https://${localIp}:${secondaryPort}?token=${ACCESS_TOKEN}` : null,
2516
+ }));
2353
2517
  return;
2354
2518
  }
2355
2519
 
@@ -2715,22 +2879,68 @@ async function handleRequest(req, res) {
2715
2879
  }
2716
2880
 
2717
2881
  export async function startViewer() {
2882
+ pluginRoutes = [];
2883
+ secondaryServer = null;
2884
+ secondaryPort = 0;
2885
+ publicAccessProtocol = 'http';
2886
+ publicAccessPort = 0;
2718
2887
  // 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
2719
2888
  await loadPlugins();
2720
2889
 
2721
2890
  // 通过插件 hook 获取 HTTPS 证书选项
2722
2891
  let httpsOptions = null;
2892
+ let httpsFromPlugin = false;
2723
2893
  try {
2724
2894
  const httpsResult = await runWaterfallHook('httpsOptions', {});
2725
2895
  httpsOptions = (httpsResult.pfx || httpsResult.cert) ? httpsResult : null;
2896
+ httpsFromPlugin = !!httpsOptions;
2726
2897
  } catch (err) {
2727
2898
  console.error('[CX Viewer] httpsOptions hook error:', err.message);
2728
2899
  }
2729
2900
 
2730
- const useHttps = !!httpsOptions;
2901
+ // 自动生成自签证书,供 HTTPS 访问使用。
2902
+ // CLI 模式下会保留 HTTP 主入口,同时额外起一个 HTTPS 入口用于二维码和局域网麦克风访问。
2903
+ if (!httpsOptions) {
2904
+ try {
2905
+ const { generateKeyPairSync, createSign, X509Certificate } = await import('node:crypto');
2906
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', {
2907
+ modulusLength: 2048,
2908
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
2909
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
2910
+ });
2911
+ // 用 node:tls 的 createSecureContext 验证 key 可用性
2912
+ // 生成自签名证书(简化版:直接用 key + cert)
2913
+ const { execSync: _execSync } = await import('node:child_process');
2914
+ const certDir = join(homedir(), '.codex', 'cx-viewer');
2915
+ const keyPath = join(certDir, 'selfsigned.key');
2916
+ const certPath = join(certDir, 'selfsigned.crt');
2917
+ // 检查已有证书是否过期或不存在
2918
+ let needGen = !existsSync(keyPath) || !existsSync(certPath);
2919
+ if (!needGen) {
2920
+ try {
2921
+ const certPem = readFileSync(certPath, 'utf-8');
2922
+ const x509 = new X509Certificate(certPem);
2923
+ needGen = new Date(x509.validTo) < new Date();
2924
+ } catch { needGen = true; }
2925
+ }
2926
+ if (needGen) {
2927
+ mkdirSync(certDir, { recursive: true });
2928
+ _execSync(`openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 365 -nodes -subj "/CN=localhost"`, { stdio: 'ignore', timeout: 10000 });
2929
+ }
2930
+ if (existsSync(keyPath) && existsSync(certPath)) {
2931
+ httpsOptions = { key: readFileSync(keyPath), cert: readFileSync(certPath) };
2932
+ }
2933
+ } catch (err) {
2934
+ // openssl 不可用或生成失败,继续用 HTTP
2935
+ if (process.env.CXV_DEBUG) console.error('[CX Viewer] Self-signed cert generation failed:', err.message);
2936
+ }
2937
+ }
2938
+
2939
+ const useHttps = !!httpsOptions && !isCliMode;
2731
2940
  const protocol = useHttps ? 'https' : 'http';
2732
2941
  serverProtocol = protocol;
2733
- if (useHttps) console.error('[CX Viewer] HTTPS mode enabled via plugin hook');
2942
+ publicAccessProtocol = protocol;
2943
+ if (useHttps) console.error(httpsFromPlugin ? '[CX Viewer] HTTPS mode enabled via plugin hook' : '[CX Viewer] HTTPS mode enabled via self-signed certificate');
2734
2944
 
2735
2945
  return new Promise((resolve, reject) => {
2736
2946
  function tryListen(port) {
@@ -2765,7 +2975,38 @@ export async function startViewer() {
2765
2975
  currentServer.listen(port, HOST, async () => {
2766
2976
  server = currentServer;
2767
2977
  actualPort = port;
2978
+ publicAccessPort = port;
2768
2979
  const url = `${serverProtocol}://127.0.0.1:${port}`;
2980
+ if (isCliMode && httpsOptions) {
2981
+ const httpsPort = await findAvailablePort(port + 1);
2982
+ if (httpsPort) {
2983
+ try {
2984
+ secondaryServer = createHttpsServer(httpsOptions, handleRequest);
2985
+ await new Promise((resolveSecondary, rejectSecondary) => {
2986
+ secondaryServer.once('error', rejectSecondary);
2987
+ secondaryServer.listen(httpsPort, HOST, () => {
2988
+ secondaryServer.off('error', rejectSecondary);
2989
+ resolveSecondary();
2990
+ });
2991
+ });
2992
+ secondaryPort = httpsPort;
2993
+ publicAccessProtocol = 'https';
2994
+ publicAccessPort = httpsPort;
2995
+ console.error('[CX Viewer] Secondary HTTPS access enabled for QR/remote browser');
2996
+ console.error(` ➜ HTTPS: https://127.0.0.1:${httpsPort}`);
2997
+ const _ips = getAllLocalIps();
2998
+ for (const _ip of _ips) {
2999
+ console.error(` ➜ HTTPS: https://${_ip}:${httpsPort}?token=${ACCESS_TOKEN}`);
3000
+ }
3001
+ } catch (err) {
3002
+ secondaryServer = null;
3003
+ secondaryPort = 0;
3004
+ publicAccessProtocol = serverProtocol;
3005
+ publicAccessPort = port;
3006
+ console.error('[CX Viewer] Secondary HTTPS server creation failed:', err.message);
3007
+ }
3008
+ }
3009
+ }
2769
3010
  if (!isCliMode) {
2770
3011
  console.error(t('server.started'));
2771
3012
  console.error(t('server.startedLocal', { protocol: serverProtocol, port }));
@@ -2773,17 +3014,23 @@ export async function startViewer() {
2773
3014
  for (const _ip of _ips) {
2774
3015
  console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, token: ACCESS_TOKEN }));
2775
3016
  }
3017
+ } else if (secondaryPort) {
3018
+ console.error(t('server.started'));
3019
+ console.error(t('server.startedLocal', { protocol: serverProtocol, port }));
3020
+ console.error(t('server.startedNetwork', { protocol: 'https', ip: getLocalIp(), port: secondaryPort, token: ACCESS_TOKEN }));
2776
3021
  }
2777
3022
  // v2.0.69 之前的版本会清空控制台,自动打开浏览器确保用户能看到界面
2778
- try {
2779
- const ccPkgPath = join(__dirname, '..', '@openai', 'codex', 'package.json');
2780
- const ccVer = JSON.parse(readFileSync(ccPkgPath, 'utf-8')).version;
2781
- const [maj, min, pat] = ccVer.split('.').map(Number);
2782
- if (maj < 2 || (maj === 2 && min === 0 && pat < 69)) {
2783
- const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
2784
- execAsync(`${cmd} ${url}`, { timeout: 5000 }).catch(() => {});
2785
- }
2786
- } catch { }
3023
+ if (!isCliMode) {
3024
+ try {
3025
+ const ccPkgPath = join(__dirname, '..', '@openai', 'codex', 'package.json');
3026
+ const ccVer = JSON.parse(readFileSync(ccPkgPath, 'utf-8')).version;
3027
+ const [maj, min, pat] = ccVer.split('.').map(Number);
3028
+ if (maj < 2 || (maj === 2 && min === 0 && pat < 69)) {
3029
+ const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
3030
+ execAsync(`${cmd} ${url}`, { timeout: 5000 }).catch(() => {});
3031
+ }
3032
+ } catch { }
3033
+ }
2787
3034
  // 工作区模式下延迟到选择工作区后再启动监听
2788
3035
  if (!isWorkspaceMode) {
2789
3036
  readModelContextSize(); // Cache model→size mapping at startup
@@ -2794,9 +3041,12 @@ export async function startViewer() {
2794
3041
  // CLI 模式下启动 WebSocket 服务 (必须 await,否则插件 hook 拿不到 upgrade listeners)
2795
3042
  if (isCliMode) {
2796
3043
  await setupTerminalWebSocket(currentServer);
3044
+ if (secondaryServer) {
3045
+ await setupTerminalWebSocket(secondaryServer);
3046
+ }
2797
3047
  }
2798
3048
  // 通知插件服务器已启动
2799
- runParallelHook('serverStarted', { port, host: HOST, url, ip: getLocalIp(), token: ACCESS_TOKEN, protocol: serverProtocol, httpServer: currentServer })
3049
+ refreshPluginRuntime()
2800
3050
  .catch(err => console.error('[CX Viewer] Plugin serverStarted hook error:', err.message));
2801
3051
  resolve(server);
2802
3052
  });
@@ -2818,7 +3068,7 @@ export async function startViewer() {
2818
3068
  async function setupTerminalWebSocket(httpServer) {
2819
3069
  try {
2820
3070
  const { WebSocketServer } = await import('ws');
2821
- const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
3071
+ const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getOutputHistoryId, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
2822
3072
  _writeToPty = writeToPty;
2823
3073
  _onPtyData = onPtyData;
2824
3074
  const wss = new WebSocketServer({ noServer: true });
@@ -2854,15 +3104,25 @@ async function setupTerminalWebSocket(httpServer) {
2854
3104
  }
2855
3105
  });
2856
3106
 
2857
- wss.on('connection', (ws) => {
3107
+ wss.on('connection', (ws, req) => {
3108
+ const sendReplayData = (data) => {
3109
+ const CHUNK_SIZE = 64 * 1024;
3110
+ for (let i = 0; i < data.length; i += CHUNK_SIZE) {
3111
+ if (ws.readyState !== 1) break;
3112
+ ws.send(JSON.stringify({ type: 'data', data: data.slice(i, i + CHUNK_SIZE) }));
3113
+ }
3114
+ };
3115
+
2858
3116
  // 发送当前 PTY 状态
2859
3117
  const state = getPtyState();
2860
- ws.send(JSON.stringify({ type: 'state', ...state }));
3118
+ const historyId = getOutputHistoryId();
3119
+ ws.send(JSON.stringify({ type: 'state', ...state, historyId }));
2861
3120
 
2862
3121
  // 发送历史输出缓冲
3122
+ const clientHistoryId = new URL(req.url, `${serverProtocol}://${req.headers.host}`).searchParams.get('historyId');
2863
3123
  const buffer = getOutputBuffer();
2864
- if (buffer) {
2865
- ws.send(JSON.stringify({ type: 'data', data: buffer }));
3124
+ if (buffer && historyId !== clientHistoryId) {
3125
+ sendReplayData(buffer);
2866
3126
  }
2867
3127
 
2868
3128
  // PTY 输出 → WebSocket
@@ -3112,6 +3372,7 @@ export function stopViewer() {
3112
3372
  }
3113
3373
  async function _doStop() {
3114
3374
  try { await Promise.race([runParallelHook('serverStopping'), new Promise(r => setTimeout(r, 3000))]); } catch { }
3375
+ pluginRoutes = [];
3115
3376
  // 如果用户未做选择,将临时文件转为正式文件
3116
3377
  if (_resumeState && _resumeState.tempFile) {
3117
3378
  try {
@@ -3154,8 +3415,14 @@ async function _doStop() {
3154
3415
 
3155
3416
  // ─── SDK Mode Exports ──────────────────────────────────────────
3156
3417
 
3157
- /** Push a JSONL entry to all SSE clients (for SDK mode). */
3418
+ /** Push a JSONL entry to the active log and all SSE clients (for SDK mode). */
3158
3419
  export function pushSdkEntry(entry) {
3420
+ if (entry && LOG_FILE) {
3421
+ try {
3422
+ appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n---\n');
3423
+ notifyStatsWorker(LOG_FILE);
3424
+ } catch {}
3425
+ }
3159
3426
  if (sendToClients) sendToClients(clients, entry);
3160
3427
  }
3161
3428