cc-viewer 1.6.306 → 1.6.308

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.
@@ -162,11 +162,13 @@ export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
162
162
  let currentSegment = [];
163
163
  let dedup = new Map();
164
164
  let sentCount = 0;
165
+ // 跨段共享 seq 守卫状态:stale checkpoint 自成段边界,必须文件级跟踪才能识破乱序
166
+ const seqState = { lastSeq: 0, lastEpoch: null };
165
167
 
166
168
  function flushSegment(nextCp) {
167
169
  if (currentSegment.length === 0) return;
168
170
  const dedupedSegment = Array.from(dedup.values());
169
- reconstructSegment(dedupedSegment, nextCp);
171
+ reconstructSegment(dedupedSegment, nextCp, seqState);
170
172
 
171
173
  let toSend = dedupedSegment;
172
174
  if (sinceMs) {
@@ -217,11 +219,13 @@ export async function streamReconstructedEntriesAsync(filePath, onSegment, opts
217
219
  let currentSegment = [];
218
220
  let dedup = new Map();
219
221
  let sentCount = 0;
222
+ // 跨段共享 seq 守卫状态:stale checkpoint 自成段边界,必须文件级跟踪才能识破乱序
223
+ const seqState = { lastSeq: 0, lastEpoch: null };
220
224
 
221
225
  async function flushSegment(nextCp) {
222
226
  if (currentSegment.length === 0) return;
223
227
  const dedupedSegment = Array.from(dedup.values());
224
- reconstructSegment(dedupedSegment, nextCp);
228
+ reconstructSegment(dedupedSegment, nextCp, seqState);
225
229
 
226
230
  let toSend = dedupedSegment;
227
231
  if (sinceMs) {
@@ -26,6 +26,14 @@ let currentWorkspacePath = null;
26
26
  let lastWorkspacePath = null; // 进程退出后保留,用于 respawn shell
27
27
  let lastPtyCols = 120;
28
28
  let lastPtyRows = 30;
29
+ // 主 PTY spawn 的在途闸:guard 在 await getPty 之前、ptyProcess 赋值在 await 之后,
30
+ // 两条同步到达的 input 消息会越过 guard 双开(首个 pty 失引用泄漏 + 输出串扰)。
31
+ // 同步占位一个 promise,并发调用复用它,绝不二次 spawn(仿 scratch-pty-manager._spawnInflight)。
32
+ let _spawnInflight = null;
33
+ // resize 入口的 cols/rows 钳制范围:上界足够宽(4K 显示器超宽终端),下界 ≥2 列/1 行
34
+ // 防 FitAddon 在 0 尺寸容器算出的 2×1 或畸形客户端的 NaN/负数毒化 lastPtyCols/Rows。
35
+ const PTY_COLS_MIN = 2, PTY_COLS_MAX = 1000;
36
+ const PTY_ROWS_MIN = 1, PTY_ROWS_MAX = 1000;
29
37
  const MAX_BUFFER = 200000;
30
38
  let batchBuffer = '';
31
39
  let batchScheduled = false;
@@ -157,10 +165,19 @@ export function _markThinkingDisplayRejected(claudePath) {
157
165
  }
158
166
 
159
167
  export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
168
+ // 等待任何在途 spawn 完成再 kill+spawn,避免与 spawnShell 双开/串台(自身串行化)。
169
+ // while 而非 if:≥3 个并发 spawn 时,A 完成后 B 会设新的 inflight=pB,单次 if 的 C
170
+ // 不会复查 pB 就 kill+spawn 致 implB/implC 并发双开——循环到真正无在途为止才放行。
171
+ while (_spawnInflight) { try { await _spawnInflight; } catch { } }
160
172
  if (ptyProcess) {
161
173
  killPty();
162
174
  }
175
+ const p = _spawnClaudeImpl(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
176
+ _spawnInflight = p;
177
+ try { return await p; } finally { if (_spawnInflight === p) _spawnInflight = null; }
178
+ }
163
179
 
180
+ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
164
181
  const pty = await getPty();
165
182
 
166
183
  fixSpawnHelperPermissions();
@@ -383,7 +400,21 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
383
400
  const chunk = chunks[idx];
384
401
  idx++;
385
402
 
386
- ptyProcess.write(chunk);
403
+ // 防御性纵深(server.js 入口已 every(string) 校验,这里是第二道):非字符串 chunk
404
+ // pty.write 抛 ERR_INVALID_ARG_TYPE、下方 chunk.endsWith 也抛——在 setTimeout 上下文中
405
+ // 脱离任何 try/catch 会变成 uncaughtException 打挂整个进程。统一拦成失败上报。
406
+ if (typeof chunk !== 'string') {
407
+ cleanup();
408
+ if (onComplete) onComplete(false);
409
+ return;
410
+ }
411
+ try {
412
+ ptyProcess.write(chunk);
413
+ } catch (e) {
414
+ cleanup();
415
+ if (onComplete) onComplete(false);
416
+ return;
417
+ }
387
418
 
388
419
  // Space, Enter, arrows need more time for inquirer to re-render
389
420
  const isToggleOrSubmit = chunk === ' ' || chunk === '\r'
@@ -403,6 +434,13 @@ export function writeToPtySequential(chunks, onComplete, opts = {}) {
403
434
  */
404
435
  export async function spawnShell() {
405
436
  if (ptyProcess) return false; // 已有进程在运行
437
+ if (_spawnInflight) return _spawnInflight; // 复用在途 spawn,防双开
438
+ const p = _spawnShellImpl();
439
+ _spawnInflight = p;
440
+ try { return await p; } finally { if (_spawnInflight === p) _spawnInflight = null; }
441
+ }
442
+
443
+ async function _spawnShellImpl() {
406
444
  const cwd = lastWorkspacePath || process.cwd();
407
445
 
408
446
  const pty = await getPty();
@@ -465,11 +503,20 @@ export async function spawnShell() {
465
503
  return true;
466
504
  }
467
505
 
506
+ // cols/rows 钳制为有限正整数:FitAddon 在 0 尺寸容器会算出 2×1,畸形客户端可能发
507
+ // NaN/0/负数——未校验直接存进 lastPtyCols/Rows 会毒化后续 pty.spawn(cols:NaN 抛错,
508
+ // spawnShell 的异常被吞 → 终端永远拉不起且无日志)。非有限值回退到上一个有效值。
509
+ function _clampDim(v, min, max, fallback) {
510
+ const n = Math.floor(Number(v));
511
+ if (!Number.isFinite(n)) return fallback;
512
+ return Math.max(min, Math.min(max, n));
513
+ }
514
+
468
515
  export function resizePty(cols, rows) {
469
- lastPtyCols = cols;
470
- lastPtyRows = rows;
516
+ lastPtyCols = _clampDim(cols, PTY_COLS_MIN, PTY_COLS_MAX, lastPtyCols);
517
+ lastPtyRows = _clampDim(rows, PTY_ROWS_MIN, PTY_ROWS_MAX, lastPtyRows);
471
518
  if (ptyProcess) {
472
- try { ptyProcess.resize(cols, rows); } catch { }
519
+ try { ptyProcess.resize(lastPtyCols, lastPtyRows); } catch { }
473
520
  }
474
521
  }
475
522
 
@@ -212,6 +212,8 @@ function imProcessPost(req, res, parsedUrl, isLocal, deps) {
212
212
  function imLogs(req, res, parsedUrl, isLocal, deps) {
213
213
  const id = platformOf(parsedUrl.pathname);
214
214
  if (!id) { notFound(res); return; }
215
+ // 弹窗打开即请求本接口 → 惰性登记该平台日志目录监听,后续写入经 im_log_update SSE 零滞后推送。
216
+ deps.ensureImWatch?.(id);
215
217
  const project = `IM_${id}`;
216
218
  let latest = null;
217
219
  try {
@@ -203,13 +203,21 @@ export function writeScratch(id, data) {
203
203
  return false;
204
204
  }
205
205
 
206
+ // cols/rows 钳制为有限正整数(同 pty-manager.resizePty 的理由:防 NaN/2×1 毒化 lastCols
207
+ // 后毒化 respawn)。非有限值回退到上一个有效值。
208
+ function _clampScratchDim(v, min, max, fallback) {
209
+ const n = Math.floor(Number(v));
210
+ if (!Number.isFinite(n)) return fallback;
211
+ return Math.max(min, Math.min(max, n));
212
+ }
213
+
206
214
  export function resizeScratch(id, cols, rows) {
207
215
  const s = ptys.get(id);
208
216
  if (!s) return;
209
- s.lastCols = cols;
210
- s.lastRows = rows;
217
+ s.lastCols = _clampScratchDim(cols, 2, 1000, s.lastCols);
218
+ s.lastRows = _clampScratchDim(rows, 1, 1000, s.lastRows);
211
219
  if (s.ptyProcess) {
212
- try { s.ptyProcess.resize(cols, rows); } catch { }
220
+ try { s.ptyProcess.resize(s.lastCols, s.lastRows); } catch { }
213
221
  }
214
222
  }
215
223
 
package/server/server.js CHANGED
@@ -77,6 +77,7 @@ import { checkAndUpdate } from './lib/updater.js';
77
77
  import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
78
78
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
79
79
  import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
80
+ import { createImLogWatcher } from './lib/im-log-watcher.js';
80
81
  import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
81
82
  import { cleanupExtractCache } from './lib/jsonl-archive.js';
82
83
  import { backupConfigs } from './lib/config-backup.js';
@@ -440,6 +441,25 @@ function getAllLocalIps() {
440
441
  // exposed via GETTERS (read fresh at request time — never captured at import), while
441
442
  // never-reassigned Maps/arrays are shared by reference. Helpers/constants that live in
442
443
  // server.js (not importable elsewhere) are funneled through here too.
444
+
445
+ // IM 日志目录监听器:仅主 web 服务启用(IM worker 无浏览器 SSE 客户端,广播无意义)。
446
+ // 惰性 ensure:「对话记录」弹窗请求 /api/im/:platform/logs 时才开始 watch 该平台目录;
447
+ // 写入即广播 im_log_update SSE → 前端零滞后重拉(详见 im-log-watcher.js / AppBase im_log_update 监听)。
448
+ const _imLogWatcher = process.env.CCV_IM_PLATFORM ? null : createImLogWatcher({
449
+ getLogDir: () => LOG_DIR,
450
+ onChange: (platform) => {
451
+ if (clients.length > 0 && sendEventToClients) {
452
+ sendEventToClients(clients, 'im_log_update', { platform, ts: Date.now() });
453
+ }
454
+ },
455
+ });
456
+
457
+ // 惰性登记 IM 日志目录监听(主服务);worker 上 _imLogWatcher 为 null → no-op。
458
+ function _ensureImWatch(id) {
459
+ try { _imLogWatcher?.ensure(id); }
460
+ catch (e) { console.warn(`[im-log-watcher] ensure(${id}) failed:`, e?.message || e); }
461
+ }
462
+
443
463
  const deps = {
444
464
  // Reassignable runtime state — must stay getters.
445
465
  get protocol() { return serverProtocol; },
@@ -479,6 +499,7 @@ const deps = {
479
499
  notifyParentPending: _notifyParentPending,
480
500
  logWatcherOpts: _logWatcherOpts,
481
501
  scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
502
+ ensureImWatch: _ensureImWatch,
482
503
  maskProfiles: _maskProfiles,
483
504
  maskApiKey: _maskApiKey,
484
505
  isMasked: _isMasked,
@@ -1154,6 +1175,8 @@ async function setupTerminalWebSocket(httpServer) {
1154
1175
  let lastLogAt = 0;
1155
1176
  return (event, bytes) => {
1156
1177
  if (event === 'start') floodCount++;
1178
+ // 该日志为 Windows 实机排洪泛卡死的观测点,macOS 下纯噪声,静默(限流逻辑本身不受影响)。
1179
+ if (process.platform === 'darwin') return;
1157
1180
  const now = Date.now();
1158
1181
  if (now - lastLogAt < 5000) return;
1159
1182
  lastLogAt = now;
@@ -1447,16 +1470,23 @@ async function setupTerminalWebSocket(httpServer) {
1447
1470
  // 把 client 提供的 seq 透传回去 — 合并 ws 后多个发送方共享一条 ws,
1448
1471
  // 只能靠 client 端按 seq 匹配自己发的请求(client 没传时也兼容,旧客户端不带 seq)。
1449
1472
  const seq = msg.seq;
1450
- if (Array.isArray(chunks) && chunks.length > 0) {
1451
- writeToPtySequential(chunks, (ok) => {
1452
- try {
1453
- const reply = { type: 'input-sequential-done', ok };
1454
- if (seq !== undefined) reply.seq = seq;
1455
- ws.send(JSON.stringify(reply));
1456
- } catch (e) {
1457
- console.warn('[server] input-sequential-done send failed:', e?.message || e);
1458
- }
1459
- }, { settleMs: msg.settleMs || 150 });
1473
+ // 元素必须全是字符串:非字符串 chunk 的 pty.write setTimeout 上下文抛
1474
+ // ERR_INVALID_ARG_TYPE(脱离本 handler 的 try/catch)→ 进程崩溃。入口先拒绝。
1475
+ // 统一回 done(含拒绝路径 ok:false):带 seq 的客户端在等这条,静默丢弃会让它
1476
+ // 挂到自身超时。复用同一发送逻辑,无效输入立即回 ok:false。
1477
+ const replyDone = (ok) => {
1478
+ try {
1479
+ const reply = { type: 'input-sequential-done', ok };
1480
+ if (seq !== undefined) reply.seq = seq;
1481
+ ws.send(JSON.stringify(reply));
1482
+ } catch (e) {
1483
+ console.warn('[server] input-sequential-done send failed:', e?.message || e);
1484
+ }
1485
+ };
1486
+ if (Array.isArray(chunks) && chunks.length > 0 && chunks.every(c => typeof c === 'string')) {
1487
+ writeToPtySequential(chunks, replyDone, { settleMs: msg.settleMs || 150 });
1488
+ } else {
1489
+ replyDone(false);
1460
1490
  }
1461
1491
  } else if (msg.type === 'ask-hook-answer') {
1462
1492
  // Client answered AskUserQuestion via hook bridge.