cx-viewer 1.0.0 → 1.0.1

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-B_TIw8e4.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-Y8uZjbOm.js} +194 -190
  5. package/dist/assets/Mobile-BMOIr-Ox.js +10 -0
  6. package/dist/assets/{_baseUniq-CXWHShmB.js → _baseUniq-rFMjvMp9.js} +1 -1
  7. package/dist/assets/{arc-qbol0NJo.js → arc--Wq2RaH7.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-COZebb1N.js → architectureDiagram-Q4EWVU46-BvEf2lP5.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-COCgzPKy.js → blockDiagram-DXYQGD6D-OPovcZG5.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-Bs5XvLEo.js → c4Diagram-AHTNJAMY-kgKyKOjo.js} +1 -1
  11. package/dist/assets/channel-73kvDnsL.js +1 -0
  12. package/dist/assets/{chunk-4BX2VUAB-CyGriz9d.js → chunk-4BX2VUAB-DzorV1it.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-DlT_q-fl.js → chunk-4TB4RGXK-CqLZ1UMO.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-NHN99ZNC.js → chunk-55IACEB6-DSt0287z.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-fK4f9Na9.js → chunk-EDXVE4YY-Ba5P02iy.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-DL-dQIq1.js → chunk-FMBD7UC4-B74A73-h.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-6uruB4VY.js → chunk-OYMX7WX6-BAHzADpU.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-CCWIqLO8.js → chunk-QZHKN3VN-BD8A_1ir.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-Cu84DMJo.js → chunk-YZCP3GAM-ClWecwcp.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-eoMr9KbW.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-eoMr9KbW.js +1 -0
  22. package/dist/assets/clone-Dcc2YhiK.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-Dqi4iU3-.js → cose-bilkent-S5V4N54A-D_ghrbfg.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-Dl-EJ9rb.js → dagre-KV5264BT-CB0vROfS.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-BYtyzF0D.js → diagram-5BDNPKRD-BpcZ1UHM.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BuETHFNU.js → diagram-G4DWMVQ6-BrFuDts4.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-Z02oZp6I.js → diagram-MMDJMWI5-CsU-BG3D.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-BpGlKv8l.js → diagram-TYMM5635-B1sb0AXF.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-C4YngeQh.js → erDiagram-SMLLAGMA-CQyK2nOQ.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-BWddFffg.js → flowDiagram-DWJPFMVM-DZBShvn4.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-Bg0jOD4m.js → ganttDiagram-T4ZO3ILL-m6fTKdLL.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Bz-3sTXZ.js → gitGraphDiagram-UUTBAWPF-DkFErNFt.js} +1 -1
  33. package/dist/assets/{graph-XiH8DkIu.js → graph-bSi0W7jc.js} +1 -1
  34. package/dist/assets/{index-BuUsWKGZ.js → index-Br7dizB3.js} +2 -2
  35. package/dist/assets/{infoDiagram-42DDH7IO-DLo-lOCE.js → infoDiagram-42DDH7IO-CSm4RFl4.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BsIzT5eO.js → ishikawaDiagram-UXIWVN3A-Bwg4bCOs.js} +1 -1
  37. package/dist/assets/{journeyDiagram-VCZTEJTY-C3bOkKqv.js → journeyDiagram-VCZTEJTY-DPHJjuBf.js} +1 -1
  38. package/dist/assets/{kanban-definition-6JOO6SKY-D-rJRNbp.js → kanban-definition-6JOO6SKY-Cv9ev85o.js} +1 -1
  39. package/dist/assets/{layout-Bqp8Hh1c.js → layout-Ds0a2T6Q.js} +1 -1
  40. package/dist/assets/{linear-BWRSipGb.js → linear-Cp7l1b4X.js} +1 -1
  41. package/dist/assets/{mermaid.core-CJa7klhs.js → mermaid.core-CqjmsXPZ.js} +4 -4
  42. package/dist/assets/{min-CQ_VyzMd.js → min-Dt_Z0wyg.js} +1 -1
  43. package/dist/assets/{mindmap-definition-QFDTVHPH-Bnj7DDSx.js → mindmap-definition-QFDTVHPH-CX3dSqAw.js} +1 -1
  44. package/dist/assets/{pieDiagram-DEJITSTG-CC8a7Ylw.js → pieDiagram-DEJITSTG-Dx1Yb4B1.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-34T5L4WZ-CrmL1WUV.js → quadrantDiagram-34T5L4WZ-DuVKMvkU.js} +1 -1
  46. package/dist/assets/{requirementDiagram-MS252O5E-TfkX4u_6.js → requirementDiagram-MS252O5E-D_k1MqxJ.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-XADWPNL6-DMwBbND7.js → sankeyDiagram-XADWPNL6-Dd_dC8NM.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-FGHM5R23-CFghMYsm.js → sequenceDiagram-FGHM5R23-zK876LNJ.js} +1 -1
  49. package/dist/assets/{stateDiagram-FHFEXIEX-1H6JIM7l.js → stateDiagram-FHFEXIEX-u5eiAg1J.js} +1 -1
  50. package/dist/assets/stateDiagram-v2-QKLJ7IA2-D0bdvVZJ.js +1 -0
  51. package/dist/assets/{timeline-definition-GMOUNBTQ-DnLMBlVa.js → timeline-definition-GMOUNBTQ-DR55BNQ6.js} +1 -1
  52. package/dist/assets/{vennDiagram-DHZGUBPP-DoIkY_KL.js → vennDiagram-DHZGUBPP-DYZc8vSs.js} +1 -1
  53. package/dist/assets/{wardley-RL74JXVD-3n5XmOD5.js → wardley-RL74JXVD-ofYR-6m2.js} +1 -1
  54. package/dist/assets/{wardleyDiagram-NUSXRM2D-UMOq3ofC.js → wardleyDiagram-NUSXRM2D-DnwarCdv.js} +1 -1
  55. package/dist/assets/{xychartDiagram-5P7HB3ND-CNDuQ5Cg.js → xychartDiagram-5P7HB3ND-1fCoOMAS.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 +290 -25
  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
@@ -54,7 +54,9 @@ export function listLocalLogs(logDir, currentProjectName) {
54
54
  }
55
55
  } catch { }
56
56
  for (const f of files) {
57
- const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl$/);
57
+ // Include active temp sessions so a first-run conversation is visible
58
+ // before it gets finalized to a permanent .jsonl file.
59
+ const match = f.match(/^(.+?)_(\d{8}_\d{6})(?:_temp)?\.jsonl$/);
58
60
  if (!match) continue;
59
61
  const ts = match[2];
60
62
  const filePath = join(projectDir, f);
@@ -1,5 +1,6 @@
1
- import { readdirSync, existsSync, readFileSync } from 'node:fs';
1
+ import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
3
4
  import { LOG_DIR } from '../findcx.js';
4
5
 
5
6
  // 动态获取(LOG_DIR 可能在运行时被 setLogDir 修改)
@@ -17,12 +18,56 @@ const HOOK_TYPES = {
17
18
  };
18
19
 
19
20
  let _plugins = [];
21
+ let _pluginStates = new Map();
22
+
23
+ function normalizeCapabilities(plugin) {
24
+ const capabilities = new Set(Array.isArray(plugin.capabilities) ? plugin.capabilities.filter(Boolean) : []);
25
+ if (plugin.hooks && typeof plugin.hooks.voiceInput === 'function') {
26
+ capabilities.add('voiceInput');
27
+ }
28
+ return Array.from(capabilities);
29
+ }
30
+
31
+ function normalizeVoiceInput(plugin) {
32
+ if (!plugin || typeof plugin !== 'object') return null;
33
+ const voiceInput = plugin.voiceInput;
34
+ if (!voiceInput || typeof voiceInput !== 'object') return null;
35
+ const appKey = typeof voiceInput.appKey === 'string' ? voiceInput.appKey.trim() : '';
36
+ const digestEndpoint = typeof voiceInput.digestEndpoint === 'string' ? voiceInput.digestEndpoint.trim() : '';
37
+ const normalized = {};
38
+ if (appKey) normalized.appKey = appKey;
39
+ if (digestEndpoint) normalized.digestEndpoint = digestEndpoint;
40
+ return Object.keys(normalized).length > 0 ? normalized : {};
41
+ }
42
+
43
+ function getModuleVersion(filePath) {
44
+ try {
45
+ return String(statSync(filePath).mtimeMs);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function extractNameFromSource(content, fallback) {
52
+ const match = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
53
+ return match ? match[1] : fallback;
54
+ }
55
+
56
+ function extractCapabilitiesFromSource(content) {
57
+ const match = content.match(/capabilities\s*:\s*\[([^\]]*)\]/);
58
+ if (!match) return [];
59
+ return match[1]
60
+ .split(',')
61
+ .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
62
+ .filter(Boolean);
63
+ }
20
64
 
21
65
  /**
22
66
  * 扫描 LOG_DIR/plugins/ 目录,动态 import 每个 .js/.mjs 文件
23
67
  */
24
68
  export async function loadPlugins() {
25
69
  _plugins = [];
70
+ _pluginStates = new Map();
26
71
 
27
72
  if (!existsSync(getPluginsDir())) return;
28
73
 
@@ -48,21 +93,90 @@ export async function loadPlugins() {
48
93
 
49
94
  for (const file of files) {
50
95
  const filePath = join(getPluginsDir(), file);
96
+ const moduleVersion = getModuleVersion(filePath);
51
97
  try {
52
- const mod = await import(`file://${filePath}`);
98
+ const source = readFileSync(filePath, 'utf-8');
99
+ const fileUrl = pathToFileURL(filePath);
100
+ fileUrl.searchParams.set('v', moduleVersion || String(Date.now()));
101
+ const mod = await import(fileUrl.href);
53
102
  const plugin = mod.default || mod;
54
103
  const name = plugin.name || file;
104
+ const hooks = (plugin.hooks && typeof plugin.hooks === 'object') ? plugin.hooks : {};
105
+ const capabilities = normalizeCapabilities(plugin);
106
+ const voiceInput = normalizeVoiceInput(plugin);
107
+ const canLoad = Object.keys(hooks).length > 0 || capabilities.length > 0;
55
108
 
56
109
  if (disabledPlugins.includes(name)) {
110
+ _pluginStates.set(file, {
111
+ name,
112
+ file,
113
+ hooks: Object.keys(hooks),
114
+ capabilities,
115
+ voiceInput,
116
+ moduleVersion,
117
+ enabled: false,
118
+ loaded: false,
119
+ loadError: null,
120
+ });
57
121
  if (SHOULD_LOG) console.error(`[CX Viewer] Plugin "${name}" is disabled, skipping.`);
58
122
  continue;
59
123
  }
60
124
 
61
- if (plugin.hooks && typeof plugin.hooks === 'object') {
62
- _plugins.push({ name, hooks: plugin.hooks, file });
63
- if (SHOULD_LOG) console.error(`[CX Viewer] Plugin loaded: ${name} (${file})`);
125
+ if (!canLoad) {
126
+ _pluginStates.set(file, {
127
+ name: extractNameFromSource(source, name),
128
+ file,
129
+ hooks: [],
130
+ capabilities: extractCapabilitiesFromSource(source),
131
+ voiceInput: null,
132
+ moduleVersion,
133
+ enabled: true,
134
+ loaded: false,
135
+ loadError: 'Plugin must export hooks or capabilities',
136
+ });
137
+ continue;
64
138
  }
139
+
140
+ _plugins.push({ name, hooks, file, capabilities, voiceInput, moduleVersion });
141
+ _pluginStates.set(file, {
142
+ name,
143
+ file,
144
+ hooks: Object.keys(hooks),
145
+ capabilities,
146
+ voiceInput,
147
+ moduleVersion,
148
+ enabled: true,
149
+ loaded: true,
150
+ loadError: null,
151
+ });
152
+ if (SHOULD_LOG) console.error(`[CX Viewer] Plugin loaded: ${name} (${file})`);
65
153
  } catch (err) {
154
+ try {
155
+ const source = readFileSync(filePath, 'utf-8');
156
+ _pluginStates.set(file, {
157
+ name: extractNameFromSource(source, file),
158
+ file,
159
+ hooks: [],
160
+ capabilities: extractCapabilitiesFromSource(source),
161
+ voiceInput: null,
162
+ moduleVersion,
163
+ enabled: true,
164
+ loaded: false,
165
+ loadError: err.message,
166
+ });
167
+ } catch {
168
+ _pluginStates.set(file, {
169
+ name: file,
170
+ file,
171
+ hooks: [],
172
+ capabilities: [],
173
+ voiceInput: null,
174
+ moduleVersion,
175
+ enabled: true,
176
+ loaded: false,
177
+ loadError: err.message,
178
+ });
179
+ }
66
180
  if (SHOULD_LOG) console.error(`[CX Viewer] Failed to load plugin "${file}":`, err.message);
67
181
  }
68
182
  }
@@ -133,6 +247,9 @@ export function getPluginsInfo() {
133
247
  }
134
248
 
135
249
  return files.map(file => {
250
+ const state = _pluginStates.get(file);
251
+ if (state) return state;
252
+
136
253
  const loaded = _plugins.find(p => p.file === file);
137
254
  let name = file;
138
255
 
@@ -155,7 +272,10 @@ export function getPluginsInfo() {
155
272
  }
156
273
 
157
274
  const hooks = loaded ? Object.keys(loaded.hooks) : [];
275
+ const capabilities = loaded ? loaded.capabilities : [];
276
+ const voiceInput = loaded ? loaded.voiceInput : null;
158
277
  const enabled = !disabledPlugins.includes(name);
159
- return { name, file, hooks, enabled };
278
+ const moduleVersion = loaded?.moduleVersion || getModuleVersion(join(getPluginsDir(), file));
279
+ return { name, file, hooks, capabilities, voiceInput, moduleVersion, enabled, loaded: !!loaded, loadError: null };
160
280
  });
161
281
  }
@@ -6,6 +6,7 @@
6
6
  * - canUseTool callback for AskUserQuestion + permission approval
7
7
  * - Streaming status tracking
8
8
  * - Multi-turn conversation via session resume
9
+ * - Completed-entry fallback when a turn only exposes final assistant text via stream events
9
10
  */
10
11
 
11
12
  import { sdkToJSONLEntry, buildStreamingStatus } from './sdk-adapter.js';
@@ -40,6 +41,9 @@ let _streamingContent = [];
40
41
  let _currentBlockData = null;
41
42
  let _streamingRequestId = null;
42
43
  let _streamThrottleTimer = null;
44
+ let _pendingFinalContent = null;
45
+ let _pendingFinalRequestMessages = null;
46
+ let _finalEntryEmittedThisTurn = false;
43
47
 
44
48
  // Callbacks registered by server.js
45
49
  let _onEntry = null;
@@ -105,6 +109,9 @@ async function _executeQuery(text) {
105
109
  // Generate stable timestamp for this turn — all entries share it for dedup
106
110
  _turnTimestamp = new Date().toISOString();
107
111
  _streamingRequestId = `sdk_${Date.now()}`;
112
+ _pendingFinalContent = null;
113
+ _pendingFinalRequestMessages = null;
114
+ _finalEntryEmittedThisTurn = false;
108
115
 
109
116
  // Accumulate user message BEFORE creating entries (this is the request)
110
117
  _accumulatedMessages.push({ role: 'user', content: text });
@@ -162,6 +169,8 @@ function _processMessage(msg) {
162
169
  // Main agent message (not sub-agent)
163
170
  if (!msg.parent_tool_use_id && msg.message) {
164
171
  _resetStreamingState();
172
+ _pendingFinalContent = null;
173
+ _pendingFinalRequestMessages = null;
165
174
 
166
175
  // Snapshot messages BEFORE adding assistant (body.messages = request history, unfiltered)
167
176
  const requestMessages = [..._accumulatedMessages];
@@ -186,6 +195,7 @@ function _processMessage(msg) {
186
195
  tools: _sdkTools,
187
196
  });
188
197
  if (_onEntry) _onEntry(entry);
198
+ _finalEntryEmittedThisTurn = true;
189
199
  }
190
200
  if (msg.session_id) _sessionId = msg.session_id;
191
201
  break;
@@ -213,6 +223,9 @@ function _processMessage(msg) {
213
223
  break;
214
224
 
215
225
  case 'result':
226
+ if (!_finalEntryEmittedThisTurn && _pendingFinalContent?.length > 0) {
227
+ _emitCompletedEntryFromStream();
228
+ }
216
229
  if (msg.session_id) _sessionId = msg.session_id;
217
230
  if (_onStreamingStatus) _onStreamingStatus(buildStreamingStatus(false));
218
231
  break;
@@ -281,6 +294,10 @@ function _processStreamEvent(event) {
281
294
  _pushStreamingEntry();
282
295
  }
283
296
  } else if (type === 'message_stop') {
297
+ if (_streamingContent.length > 0) {
298
+ _pendingFinalContent = [..._streamingContent];
299
+ _pendingFinalRequestMessages = [..._accumulatedMessages];
300
+ }
284
301
  _resetStreamingState();
285
302
  }
286
303
  }
@@ -336,6 +353,41 @@ function _resetStreamingState() {
336
353
  }
337
354
  }
338
355
 
356
+ function _emitCompletedEntryFromStream() {
357
+ if (!_onEntry || !_pendingFinalContent?.length) return;
358
+
359
+ const requestMessages = _pendingFinalRequestMessages || [..._accumulatedMessages];
360
+ const content = _filterInteractiveContent(_pendingFinalContent);
361
+ const syntheticMsg = {
362
+ message: {
363
+ id: _streamingRequestId,
364
+ type: 'message',
365
+ role: 'assistant',
366
+ model: _model,
367
+ content,
368
+ stop_reason: 'end_turn',
369
+ usage: { input_tokens: 0, output_tokens: 0 },
370
+ },
371
+ };
372
+
373
+ const entry = sdkToJSONLEntry(syntheticMsg, requestMessages, _model, _projectName, {
374
+ timestamp: _turnTimestamp,
375
+ tools: _sdkTools,
376
+ });
377
+ _onEntry(entry);
378
+ _finalEntryEmittedThisTurn = true;
379
+
380
+ const lastMsg = _accumulatedMessages[_accumulatedMessages.length - 1];
381
+ if (lastMsg && lastMsg.role === 'assistant' && Array.isArray(lastMsg.content)) {
382
+ lastMsg.content = [...lastMsg.content, ...content];
383
+ } else {
384
+ _accumulatedMessages.push({ role: 'assistant', content });
385
+ }
386
+
387
+ _pendingFinalContent = null;
388
+ _pendingFinalRequestMessages = null;
389
+ }
390
+
339
391
  /**
340
392
  * canUseTool callback — handles AskUserQuestion + permission approval.
341
393
  */
@@ -447,6 +499,9 @@ function _resetFullState() {
447
499
  _accumulatedMessages = [];
448
500
  _turnTimestamp = null;
449
501
  _messageQueue = [];
502
+ _pendingFinalContent = null;
503
+ _pendingFinalRequestMessages = null;
504
+ _finalEntryEmittedThisTurn = false;
450
505
  _resetStreamingState();
451
506
  _streamingRequestId = null;
452
507
  // Reject all pending approvals
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cx-viewer",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Codex Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/pty-manager.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { resolveNativePath, BINARY_NAME } from './findcx.js';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { join, dirname } from 'node:path';
4
- import { chmodSync, statSync } from 'node:fs';
5
- import { platform, arch } from 'node:os';
3
+ import { join, dirname, basename } from 'node:path';
4
+ import { chmodSync, statSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
5
+ import { platform, arch, homedir } from 'node:os';
6
6
  import { execSync } from 'node:child_process';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
@@ -21,6 +21,16 @@ const MAX_BUFFER = 200000;
21
21
  let batchBuffer = '';
22
22
  let batchScheduled = false;
23
23
  let _ptyImportForTests = null;
24
+ let outputHistoryPath = null;
25
+
26
+ function ensureOutputHistory({ reset = false } = {}) {
27
+ const historyDir = join(homedir(), '.codex', 'cx-viewer', 'runtime');
28
+ mkdirSync(historyDir, { recursive: true });
29
+ if (reset || !outputHistoryPath) {
30
+ outputHistoryPath = join(historyDir, `terminal-history-${Date.now()}.log`);
31
+ appendFileSync(outputHistoryPath, '', 'utf8');
32
+ }
33
+ }
24
34
 
25
35
  export function _setPtyImportForTests(fn) {
26
36
  _ptyImportForTests = fn;
@@ -78,6 +88,9 @@ function flushBatch() {
78
88
  if (!batchBuffer) return;
79
89
  const chunk = batchBuffer;
80
90
  batchBuffer = '';
91
+ if (outputHistoryPath) {
92
+ try { appendFileSync(outputHistoryPath, chunk, 'utf8'); } catch { }
93
+ }
81
94
  for (const cb of dataListeners) {
82
95
  try { cb(chunk); } catch { }
83
96
  }
@@ -187,6 +200,7 @@ export async function spawnCodex(proxyPort, cwd, extraArgs = [], codexPath = nul
187
200
 
188
201
  lastExitCode = null;
189
202
  outputBuffer = '';
203
+ ensureOutputHistory({ reset: true });
190
204
  currentWorkspacePath = cwd || process.cwd();
191
205
  lastWorkspacePath = currentWorkspacePath;
192
206
 
@@ -309,6 +323,7 @@ export async function spawnShell() {
309
323
 
310
324
  lastExitCode = null;
311
325
  currentWorkspacePath = cwd;
326
+ ensureOutputHistory();
312
327
 
313
328
  // Clean env: remove cx-viewer specific vars so child shells don't inherit them
314
329
  // (prevents CXVIEWER_PORT leaking to non-cx-viewer Codex instances)
@@ -403,5 +418,14 @@ export function getCurrentWorkspace() {
403
418
  }
404
419
 
405
420
  export function getOutputBuffer() {
421
+ if (outputHistoryPath) {
422
+ try {
423
+ return readFileSync(outputHistoryPath, 'utf8');
424
+ } catch { }
425
+ }
406
426
  return outputBuffer;
407
427
  }
428
+
429
+ export function getOutputHistoryId() {
430
+ return outputHistoryPath ? basename(outputHistoryPath) : null;
431
+ }