cc-viewer 1.6.294 → 1.6.296
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/cli.js +7 -2
- package/dist/assets/App-BeCGow-I.js +2 -0
- package/dist/assets/{MdxEditorPanel-B8xrlDZJ.js → MdxEditorPanel-D52b5qxi.js} +1 -1
- package/dist/assets/{Mobile-fsi8-Lpb.js → Mobile-8fflztx7.js} +1 -1
- package/dist/assets/index-DtpelJc4.js +2 -0
- package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +3 -3
- package/package.json +1 -1
- package/server/i18n.js +224 -8
- package/server/interceptor.js +23 -19
- package/server/lib/adapters/dingtalk-adapter.js +62 -0
- package/server/lib/adapters/discord-adapter.js +35 -0
- package/server/lib/adapters/feishu-adapter.js +37 -0
- package/server/lib/ask-store.js +19 -90
- package/server/lib/async-file-lock.js +123 -0
- package/server/lib/async-write-queue.js +131 -0
- package/server/lib/git-diff.js +4 -1
- package/server/lib/im-bridge-core.js +119 -14
- package/server/lib/im-config.js +11 -6
- package/server/lib/im-process-manager.js +1 -1
- package/server/lib/jsonl-archive.js +0 -1
- package/server/lib/log-management.js +46 -99
- package/server/lib/log-stream.js +102 -8
- package/server/lib/log-watcher.js +231 -178
- package/server/lib/plugin-manager.js +1 -1
- package/server/lib/updater.js +4 -2
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +2 -2
- package/server/routes/dingtalk.js +2 -0
- package/server/routes/events.js +3 -3
- package/server/routes/files-fs.js +4 -4
- package/server/routes/logs.js +5 -5
- package/server/routes/project-meta.js +18 -1
- package/server/routes/workspaces.js +10 -13
- package/server/server.js +33 -25
- package/server/workspace-registry.js +26 -72
- package/dist/assets/App-C66LoBEz.js +0 -2
- package/dist/assets/index-BTZqk5O5.js +0 -2
- package/dist/assets/seqResourceLoaders-6k4uXcNn.js +0 -2
package/server/lib/log-stream.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
14
|
+
import { open as fsOpen, stat as fsStat } from 'node:fs/promises';
|
|
14
15
|
import { isCheckpointEntry, isDeltaEntry, reconstructSegment } from './delta-reconstructor.js';
|
|
15
16
|
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
16
17
|
|
|
@@ -56,15 +57,55 @@ function* iterateRawEntries(filePath) {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 异步 Generator:分块读取 JSONL 文件,逐条 yield 原始 JSON 字符串。
|
|
62
|
+
* 使用 fs.promises.open + fileHandle.read,不阻塞事件循环。
|
|
63
|
+
*/
|
|
64
|
+
async function* iterateRawEntriesAsync(filePath) {
|
|
65
|
+
filePath = resolveJsonlPath(filePath);
|
|
66
|
+
let fh;
|
|
67
|
+
try {
|
|
68
|
+
const st = await fsStat(filePath);
|
|
69
|
+
if (st.size === 0) return;
|
|
70
|
+
fh = await fsOpen(filePath, 'r');
|
|
71
|
+
const fileSize = st.size;
|
|
72
|
+
const buf = Buffer.alloc(Math.min(READ_CHUNK_SIZE, fileSize));
|
|
73
|
+
let offset = 0;
|
|
74
|
+
let pending = '';
|
|
75
|
+
|
|
76
|
+
while (offset < fileSize) {
|
|
77
|
+
const toRead = Math.min(buf.length, fileSize - offset);
|
|
78
|
+
const { bytesRead } = await fh.read(buf, 0, toRead, offset);
|
|
79
|
+
if (bytesRead === 0) break;
|
|
80
|
+
offset += bytesRead;
|
|
81
|
+
|
|
82
|
+
const raw = pending + buf.toString('utf-8', 0, bytesRead);
|
|
83
|
+
const parts = raw.split(SEPARATOR);
|
|
84
|
+
pending = parts.pop() || '';
|
|
85
|
+
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
const trimmed = part.trim();
|
|
88
|
+
if (trimmed) yield trimmed;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (pending.trim()) {
|
|
93
|
+
yield pending.trim();
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
if (fh) await fh.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
59
100
|
/**
|
|
60
101
|
* 轻量预扫描:统计条目总数(原始条目数,不去重)。
|
|
61
102
|
* 用于 SSE load_start 的 total 字段(进度显示)。
|
|
62
103
|
*/
|
|
63
|
-
export function countLogEntries(filePath) {
|
|
104
|
+
export async function countLogEntries(filePath) {
|
|
64
105
|
filePath = resolveJsonlPath(filePath);
|
|
65
106
|
if (!existsSync(filePath)) return 0;
|
|
66
107
|
let count = 0;
|
|
67
|
-
for (const _ of
|
|
108
|
+
for await (const _ of iterateRawEntriesAsync(filePath)) { count++; }
|
|
68
109
|
return count;
|
|
69
110
|
}
|
|
70
111
|
|
|
@@ -157,6 +198,61 @@ export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
|
|
|
157
198
|
return sentCount;
|
|
158
199
|
}
|
|
159
200
|
|
|
201
|
+
export async function streamReconstructedEntriesAsync(filePath, onSegment, opts = {}) {
|
|
202
|
+
filePath = resolveJsonlPath(filePath);
|
|
203
|
+
if (!existsSync(filePath)) return 0;
|
|
204
|
+
try {
|
|
205
|
+
const st = await fsStat(filePath);
|
|
206
|
+
if (st.size === 0) return 0;
|
|
207
|
+
} catch { return 0; }
|
|
208
|
+
|
|
209
|
+
const sinceMs = opts.since ? new Date(opts.since).getTime() : 0;
|
|
210
|
+
let currentSegment = [];
|
|
211
|
+
let dedup = new Map();
|
|
212
|
+
let sentCount = 0;
|
|
213
|
+
|
|
214
|
+
async function flushSegment(nextCp) {
|
|
215
|
+
if (currentSegment.length === 0) return;
|
|
216
|
+
const dedupedSegment = Array.from(dedup.values());
|
|
217
|
+
reconstructSegment(dedupedSegment, nextCp);
|
|
218
|
+
|
|
219
|
+
let toSend = dedupedSegment;
|
|
220
|
+
if (sinceMs) {
|
|
221
|
+
toSend = dedupedSegment.filter(e => {
|
|
222
|
+
const ts = e.timestamp ? new Date(e.timestamp).getTime() : 0;
|
|
223
|
+
return ts > sinceMs;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (toSend.length > 0) {
|
|
227
|
+
await onSegment(toSend);
|
|
228
|
+
sentCount += toSend.length;
|
|
229
|
+
}
|
|
230
|
+
currentSegment = [];
|
|
231
|
+
dedup = new Map();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for await (const rawEntry of iterateRawEntriesAsync(filePath)) {
|
|
235
|
+
let entry;
|
|
236
|
+
try { entry = JSON.parse(rawEntry); } catch { continue; }
|
|
237
|
+
|
|
238
|
+
if (isSegmentBoundary(entry) && currentSegment.length > 0) {
|
|
239
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
240
|
+
const last = currentSegment[currentSegment.length - 1];
|
|
241
|
+
const lastKey = `${last.timestamp}|${last.url}`;
|
|
242
|
+
if (key !== lastKey) {
|
|
243
|
+
await flushSegment(entry);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
248
|
+
dedup.set(key, entry);
|
|
249
|
+
currentSegment.push(entry);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await flushSegment(null);
|
|
253
|
+
return sentCount;
|
|
254
|
+
}
|
|
255
|
+
|
|
160
256
|
// ============================================================================
|
|
161
257
|
// 异步 API — 用于 SSE/HTTP:不做重建,直接发原始 JSON 字符串
|
|
162
258
|
// ============================================================================
|
|
@@ -192,16 +288,15 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
|
192
288
|
const onScan = opts.onScan || null;
|
|
193
289
|
const onReady = opts.onReady || null;
|
|
194
290
|
|
|
195
|
-
//
|
|
291
|
+
// 第一遍:异步 generator 逐条读取 → dedup Map 存原始字符串(不 parse)
|
|
196
292
|
// 内存 = 去重后的原始字符串总量 ≈ 文件大小的一半(inProgress 被 completed 覆盖)
|
|
197
293
|
const dedup = new Map();
|
|
198
|
-
for (const raw of
|
|
294
|
+
for await (const raw of iterateRawEntriesAsync(filePath)) {
|
|
199
295
|
if (onScan) onScan(raw);
|
|
200
296
|
const key = extractDedupKey(raw);
|
|
201
297
|
if (key) {
|
|
202
298
|
dedup.set(key, raw);
|
|
203
299
|
} else {
|
|
204
|
-
// 无法提取 key 的条目直接保留(用自增 id 避免被覆盖)
|
|
205
300
|
dedup.set(`__nokey_${dedup.size}`, raw);
|
|
206
301
|
}
|
|
207
302
|
}
|
|
@@ -270,15 +365,14 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
|
270
365
|
* @param {{ before: string, limit: number }} opts
|
|
271
366
|
* @returns {{ entries: string[], hasMore: boolean, oldestTimestamp: string, count: number }}
|
|
272
367
|
*/
|
|
273
|
-
export function readPagedEntries(filePath, { before, limit }) {
|
|
368
|
+
export async function readPagedEntries(filePath, { before, limit }) {
|
|
274
369
|
filePath = resolveJsonlPath(filePath);
|
|
275
370
|
if (!existsSync(filePath)) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
|
|
276
371
|
const stat = statSync(filePath);
|
|
277
372
|
if (stat.size === 0) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
|
|
278
373
|
|
|
279
|
-
// 去重
|
|
280
374
|
const dedup = new Map();
|
|
281
|
-
for (const raw of
|
|
375
|
+
for await (const raw of iterateRawEntriesAsync(filePath)) {
|
|
282
376
|
const key = extractDedupKey(raw);
|
|
283
377
|
if (key) {
|
|
284
378
|
dedup.set(key, raw);
|
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
import { readFileSync, existsSync, watchFile, unwatchFile,
|
|
1
|
+
import { readFileSync, existsSync, watch, watchFile, unwatchFile, statSync } from 'node:fs';
|
|
2
|
+
import { open as fsOpen, stat as fsStat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, basename } from 'node:path';
|
|
2
4
|
import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
|
|
3
5
|
import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
|
|
4
6
|
import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
|
|
5
|
-
import { countLogEntries,
|
|
7
|
+
import { countLogEntries, streamReconstructedEntriesAsync } from './log-stream.js';
|
|
6
8
|
import { enrichEntry } from './enrich-plan-input.js';
|
|
7
9
|
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
8
10
|
|
|
9
|
-
// 跟踪所有被 watch
|
|
11
|
+
// 跟踪所有被 watch 的日志文件。value: fileState 对象(外部只用 .has()/.keys())
|
|
10
12
|
const watchedFiles = new Map();
|
|
11
13
|
|
|
14
|
+
// 目录级 fs.watch 实例注册表(事件驱动,替代 per-file 轮询)
|
|
15
|
+
const _dirWatchers = new Map();
|
|
16
|
+
|
|
17
|
+
// Windows 单次 appendFileSync 触发 2+ 事件,防抖合并
|
|
18
|
+
const FSWATCH_DEBOUNCE_MS = 80;
|
|
19
|
+
|
|
20
|
+
// 安全网慢轮询:fs.watch 可能漏事件(buffer overflow 等),冷 fallback 兜底
|
|
21
|
+
const SAFETY_POLL_MS = 5000;
|
|
22
|
+
|
|
23
|
+
const FORCE_POLL = process.env.CCV_FORCE_POLL === '1';
|
|
24
|
+
|
|
12
25
|
/**
|
|
13
26
|
* Read and parse a JSONL log file.
|
|
14
27
|
* @param {string} logFile - absolute path to the log file
|
|
15
28
|
* @returns {Array} parsed and deduplicated entries
|
|
16
29
|
*/
|
|
17
30
|
export function readLogFile(logFile) {
|
|
18
|
-
// 透明支持归档后的 .jsonl.zip。active log(被 watchLogFile 追加写入的文件)不会被归档
|
|
19
|
-
// (archive 拒绝最新文件),watchLogFile 内部的 openSync/readSync 因此无需走 resolveJsonlPath。
|
|
20
31
|
logFile = resolveJsonlPath(logFile);
|
|
21
32
|
if (!existsSync(logFile)) {
|
|
22
33
|
return [];
|
|
@@ -24,8 +35,6 @@ export function readLogFile(logFile) {
|
|
|
24
35
|
|
|
25
36
|
try {
|
|
26
37
|
const content = readFileSync(logFile, 'utf-8');
|
|
27
|
-
// Windows 上若 writer 使用 os.EOL,分隔符会变 \r\n---\r\n。固定 LF 切会失败 → 整文件
|
|
28
|
-
// 解析成一条乱码或漏。CRLF-tolerant split 把两边都 cover 住。
|
|
29
38
|
const entries = content.split(/\r?\n---\r?\n/).filter(line => line.trim());
|
|
30
39
|
const parsed = entries.map(entry => {
|
|
31
40
|
try {
|
|
@@ -34,7 +43,6 @@ export function readLogFile(logFile) {
|
|
|
34
43
|
return null;
|
|
35
44
|
}
|
|
36
45
|
}).filter(Boolean);
|
|
37
|
-
// 去重:同一 timestamp+url 的条目,后出现的(带 response)覆盖先出现的(在途)
|
|
38
46
|
const map = new Map();
|
|
39
47
|
for (const entry of parsed) {
|
|
40
48
|
const key = `${entry.timestamp}|${entry.url}`;
|
|
@@ -47,8 +55,6 @@ export function readLogFile(logFile) {
|
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
// SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
|
|
51
|
-
// 与 server.js 同名常量值保持一致(避免循环依赖,此处单独 mirror)。
|
|
52
58
|
const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
|
|
53
59
|
|
|
54
60
|
function _removeClient(clients, client) {
|
|
@@ -56,14 +62,7 @@ function _removeClient(clients, client) {
|
|
|
56
62
|
if (idx !== -1) clients.splice(idx, 1);
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
/**
|
|
60
|
-
* 向单个 SSE client 安全写入 payload。
|
|
61
|
-
* - 写错或 client.destroyed/!writable:立即从 clients 数组移除
|
|
62
|
-
* - write 返回 false(写缓冲满):标记时间戳,超过 SSE_BACKPRESSURE_TIMEOUT_MS 仍未排空则剔除并 end()
|
|
63
|
-
* - drain 后重置 _sseBackpressureSince=0,下次 backpressure 重新计时
|
|
64
|
-
*/
|
|
65
65
|
function _safeSseWrite(clients, client, payload) {
|
|
66
|
-
// 仅在显式标记 destroyed/writable=false 时剔除;undefined(如老 mock)按"活"处理。
|
|
67
66
|
if (client.destroyed === true || client.writable === false) {
|
|
68
67
|
_removeClient(clients, client);
|
|
69
68
|
return false;
|
|
@@ -88,25 +87,13 @@ function _safeSseWrite(clients, client, payload) {
|
|
|
88
87
|
return true;
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
/**
|
|
92
|
-
* Send an SSE entry to all connected clients.
|
|
93
|
-
* @param {Array} clients - SSE client array
|
|
94
|
-
* @param {object} entry - parsed log entry
|
|
95
|
-
*/
|
|
96
90
|
export function sendToClients(clients, entry) {
|
|
97
91
|
const payload = `data: ${JSON.stringify(entry)}\n\n`;
|
|
98
|
-
// 倒序遍历允许循环内安全 splice
|
|
99
92
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
100
93
|
_safeSseWrite(clients, clients[i], payload);
|
|
101
94
|
}
|
|
102
95
|
}
|
|
103
96
|
|
|
104
|
-
/**
|
|
105
|
-
* Send a named SSE event to all connected clients.
|
|
106
|
-
* @param {Array} clients - SSE client array
|
|
107
|
-
* @param {string} eventName - SSE event name
|
|
108
|
-
* @param {object} data - event payload
|
|
109
|
-
*/
|
|
110
97
|
export function sendEventToClients(clients, eventName, data) {
|
|
111
98
|
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
112
99
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
@@ -114,11 +101,6 @@ export function sendEventToClients(clients, eventName, data) {
|
|
|
114
101
|
}
|
|
115
102
|
}
|
|
116
103
|
|
|
117
|
-
/**
|
|
118
|
-
* 旋转处理器专用:发送已序列化的 load_chunk segment 数据。
|
|
119
|
-
* @param {Array} clients - SSE client array
|
|
120
|
-
* @param {string} dataJson - segment 已被调用方 JSON.stringify
|
|
121
|
-
*/
|
|
122
104
|
export function sendChunkToClients(clients, dataJson) {
|
|
123
105
|
const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
|
|
124
106
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
@@ -126,24 +108,185 @@ export function sendChunkToClients(clients, dataJson) {
|
|
|
126
108
|
}
|
|
127
109
|
}
|
|
128
110
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
111
|
+
// --- 轮转切换(抽取公共逻辑) ---
|
|
112
|
+
|
|
113
|
+
async function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
|
|
114
|
+
_unwatchSingleFile(logFile);
|
|
115
|
+
const total = await countLogEntries(currentLogFile);
|
|
116
|
+
sendEventToClients(clients, 'load_start', { total, incremental: false });
|
|
117
|
+
await streamReconstructedEntriesAsync(currentLogFile, (segment) => {
|
|
118
|
+
sendChunkToClients(clients, JSON.stringify(segment));
|
|
119
|
+
});
|
|
120
|
+
sendEventToClients(clients, 'load_end', {});
|
|
121
|
+
watchLogFile({ ...opts, logFile: currentLogFile });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- 增量读 + 解析 + 广播(独立于触发机制) ---
|
|
125
|
+
|
|
126
|
+
async function _readDelta(state) {
|
|
127
|
+
if (state._reading) return; // 防止并发调用(debounce + safetyTimer 可能重叠)
|
|
128
|
+
state._reading = true;
|
|
129
|
+
const { logFile, opts, reconstructor } = state;
|
|
130
|
+
const { clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
|
|
131
|
+
try {
|
|
132
|
+
const st = await fsStat(logFile);
|
|
133
|
+
const currentSize = st.size;
|
|
134
|
+
|
|
135
|
+
if (currentSize < state.lastByteOffset) {
|
|
136
|
+
state.lastByteOffset = 0;
|
|
137
|
+
state.pendingTail = '';
|
|
138
|
+
reconstructor.reset();
|
|
139
|
+
|
|
140
|
+
const currentLogFile = getLogFile();
|
|
141
|
+
if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
|
|
142
|
+
await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (currentSize <= state.lastByteOffset) return;
|
|
148
|
+
|
|
149
|
+
const bytesToRead = currentSize - state.lastByteOffset;
|
|
150
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
151
|
+
const fh = await fsOpen(logFile, 'r');
|
|
152
|
+
try {
|
|
153
|
+
await fh.read(buf, 0, bytesToRead, state.lastByteOffset);
|
|
154
|
+
} finally {
|
|
155
|
+
await fh.close();
|
|
156
|
+
}
|
|
157
|
+
state.lastByteOffset = currentSize;
|
|
158
|
+
|
|
159
|
+
const raw = state.pendingTail + buf.toString('utf-8');
|
|
160
|
+
const parts = raw.split('\n---\n');
|
|
161
|
+
state.pendingTail = parts.pop() || '';
|
|
162
|
+
|
|
163
|
+
if (parts.length === 0 && state.pendingTail.trim()) {
|
|
164
|
+
try {
|
|
165
|
+
JSON.parse(state.pendingTail);
|
|
166
|
+
parts.push(state.pendingTail);
|
|
167
|
+
state.pendingTail = '';
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const validParts = parts.filter(p => p.trim());
|
|
172
|
+
if (validParts.length > 0) {
|
|
173
|
+
validParts.forEach(entry => {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(entry);
|
|
176
|
+
if (!parsed.pid) parsed.pid = getClaudePid();
|
|
177
|
+
reconstructor.reconstruct(parsed);
|
|
178
|
+
try { enrichEntry(parsed); } catch {}
|
|
179
|
+
sendToClients(clients, parsed);
|
|
180
|
+
runParallelHook('onNewEntry', parsed).catch(() => {});
|
|
181
|
+
if (isMainAgentEntry(parsed) && !parsed.inProgress) {
|
|
182
|
+
const cached = extractCachedContent(parsed);
|
|
183
|
+
if (cached) sendEventToClients(clients, 'kv_cache_content', cached);
|
|
184
|
+
const usage = parsed.response?.body?.usage;
|
|
185
|
+
if (usage) {
|
|
186
|
+
const contextSize = getContextSizeForModel(parsed.body?.model);
|
|
187
|
+
const cwData = buildContextWindowEvent(usage, contextSize);
|
|
188
|
+
if (cwData) sendEventToClients(clients, 'context_window', cwData);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch {}
|
|
192
|
+
});
|
|
193
|
+
notifyStatsWorker(logFile);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const currentLogFile = getLogFile();
|
|
197
|
+
if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
|
|
198
|
+
await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// File not yet created or transient read error
|
|
202
|
+
} finally {
|
|
203
|
+
state._reading = false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- 目录级 fs.watch 管理 ---
|
|
208
|
+
|
|
209
|
+
function _getOrCreateDirWatcher(dir) {
|
|
210
|
+
if (_dirWatchers.has(dir)) return _dirWatchers.get(dir);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const files = new Map();
|
|
214
|
+
const watcher = watch(dir, (eventType, filename) => {
|
|
215
|
+
if (!filename) {
|
|
216
|
+
for (const [, fileState] of files) {
|
|
217
|
+
_scheduleDebouncedRead(fileState);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const fileState = files.get(filename);
|
|
222
|
+
if (fileState) _scheduleDebouncedRead(fileState);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
watcher.on('error', () => {
|
|
226
|
+
for (const [, fileState] of files) {
|
|
227
|
+
_fallbackToPolling(fileState);
|
|
228
|
+
}
|
|
229
|
+
try { watcher.close(); } catch {}
|
|
230
|
+
_dirWatchers.delete(dir);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const entry = { watcher, files };
|
|
234
|
+
_dirWatchers.set(dir, entry);
|
|
235
|
+
return entry;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _scheduleDebouncedRead(fileState) {
|
|
242
|
+
if (fileState.debounceTimer) return;
|
|
243
|
+
fileState.debounceTimer = setTimeout(() => {
|
|
244
|
+
fileState.debounceTimer = null;
|
|
245
|
+
_readDelta(fileState);
|
|
246
|
+
}, FSWATCH_DEBOUNCE_MS);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _fallbackToPolling(fileState) {
|
|
250
|
+
if (fileState.polling) return;
|
|
251
|
+
fileState.polling = true;
|
|
252
|
+
watchFile(fileState.logFile, { interval: 500 }, () => {
|
|
253
|
+
_readDelta(fileState);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _unwatchSingleFile(logFile) {
|
|
258
|
+
const fileState = watchedFiles.get(logFile);
|
|
259
|
+
watchedFiles.delete(logFile);
|
|
260
|
+
|
|
261
|
+
if (!fileState) return;
|
|
262
|
+
|
|
263
|
+
if (fileState.debounceTimer) clearTimeout(fileState.debounceTimer);
|
|
264
|
+
if (fileState.safetyTimer) clearInterval(fileState.safetyTimer);
|
|
265
|
+
|
|
266
|
+
if (fileState.polling) {
|
|
267
|
+
try { unwatchFile(logFile); } catch {}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const dir = dirname(logFile);
|
|
272
|
+
const filename = basename(logFile);
|
|
273
|
+
const dirEntry = _dirWatchers.get(dir);
|
|
274
|
+
if (dirEntry) {
|
|
275
|
+
dirEntry.files.delete(filename);
|
|
276
|
+
if (dirEntry.files.size === 0) {
|
|
277
|
+
try { dirEntry.watcher.close(); } catch {}
|
|
278
|
+
_dirWatchers.delete(dir);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- 公开 API ---
|
|
284
|
+
|
|
139
285
|
export function watchLogFile(opts) {
|
|
140
|
-
const { logFile
|
|
286
|
+
const { logFile } = opts;
|
|
141
287
|
if (watchedFiles.has(logFile)) return;
|
|
142
288
|
|
|
143
|
-
// Track byte offset instead of string length — avoids full-file re-read on every poll
|
|
144
289
|
let lastByteOffset = 0;
|
|
145
|
-
let pendingTail = ''; // incomplete entry carried across polls
|
|
146
|
-
// Delta storage: 增量重建器,用于逐条重建 mainAgent delta 条目
|
|
147
290
|
const _reconstructor = createIncrementalReconstructor();
|
|
148
291
|
try {
|
|
149
292
|
if (existsSync(logFile)) {
|
|
@@ -151,150 +294,60 @@ export function watchLogFile(opts) {
|
|
|
151
294
|
}
|
|
152
295
|
} catch {}
|
|
153
296
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// 文件被清空可能是轮转信号,立即检查是否已切换到新文件
|
|
166
|
-
const currentLogFile = getLogFile();
|
|
167
|
-
if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
|
|
168
|
-
unwatchFile(logFile);
|
|
169
|
-
watchedFiles.delete(logFile);
|
|
170
|
-
|
|
171
|
-
// 流式分段广播,避免全量加载 OOM;走 _safeSseWrite 包装做 backpressure / dead-client 清理
|
|
172
|
-
const rotTotal = countLogEntries(currentLogFile);
|
|
173
|
-
sendEventToClients(clients, 'load_start', { total: rotTotal, incremental: false });
|
|
174
|
-
streamReconstructedEntries(currentLogFile, (segment) => {
|
|
175
|
-
sendChunkToClients(clients, JSON.stringify(segment));
|
|
176
|
-
});
|
|
177
|
-
sendEventToClients(clients, 'load_end', {});
|
|
178
|
-
watchLogFile({ ...opts, logFile: currentLogFile });
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
297
|
+
const fileState = {
|
|
298
|
+
logFile,
|
|
299
|
+
opts,
|
|
300
|
+
reconstructor: _reconstructor,
|
|
301
|
+
lastByteOffset,
|
|
302
|
+
pendingTail: '',
|
|
303
|
+
debounceTimer: null,
|
|
304
|
+
safetyTimer: null,
|
|
305
|
+
polling: false,
|
|
306
|
+
};
|
|
182
307
|
|
|
183
|
-
|
|
308
|
+
watchedFiles.set(logFile, fileState);
|
|
184
309
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
readSync(fd, buf, 0, bytesToRead, lastByteOffset);
|
|
191
|
-
} finally {
|
|
192
|
-
closeSync(fd);
|
|
193
|
-
}
|
|
194
|
-
lastByteOffset = currentSize;
|
|
310
|
+
if (FORCE_POLL) {
|
|
311
|
+
_fallbackToPolling(fileState);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
195
314
|
|
|
196
|
-
|
|
197
|
-
|
|
315
|
+
const dir = dirname(logFile);
|
|
316
|
+
const filename = basename(logFile);
|
|
317
|
+
const dirEntry = _getOrCreateDirWatcher(dir);
|
|
198
318
|
|
|
199
|
-
|
|
200
|
-
|
|
319
|
+
if (!dirEntry) {
|
|
320
|
+
_fallbackToPolling(fileState);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
201
323
|
|
|
202
|
-
|
|
203
|
-
// (happens when the file ends without a trailing \n---\n)
|
|
204
|
-
if (parts.length === 0 && pendingTail.trim()) {
|
|
205
|
-
try {
|
|
206
|
-
JSON.parse(pendingTail);
|
|
207
|
-
// Valid JSON — treat as complete entry
|
|
208
|
-
parts.push(pendingTail);
|
|
209
|
-
pendingTail = '';
|
|
210
|
-
} catch {
|
|
211
|
-
// Incomplete — keep in pendingTail for next poll
|
|
212
|
-
}
|
|
213
|
-
}
|
|
324
|
+
dirEntry.files.set(filename, fileState);
|
|
214
325
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const parsed = JSON.parse(entry);
|
|
220
|
-
if (!parsed.pid) {
|
|
221
|
-
parsed.pid = getClaudePid();
|
|
222
|
-
}
|
|
223
|
-
// Delta storage: reconstruct before push — 确保前端收到完整 messages
|
|
224
|
-
_reconstructor.reconstruct(parsed);
|
|
225
|
-
// ExitPlanMode V2 input 服务端补全(详见 enrich-plan-input.js#enrichEntry JSDoc)。
|
|
226
|
-
// 同步实现:候选扫描天然廉价(无 ExitPlanMode 块直接 0ms 返回);命中
|
|
227
|
-
// 路径由 transcript 64MB 上限 + miss 30s TTL + path mtime 校验三层兜
|
|
228
|
-
// 住,最坏 ~150ms。如未来 hit 比例显著上升再考虑 setImmediate 拆分。
|
|
229
|
-
try { enrichEntry(parsed); } catch { /* 静默回退 */ }
|
|
230
|
-
sendToClients(clients, parsed);
|
|
231
|
-
runParallelHook('onNewEntry', parsed).catch(() => {});
|
|
232
|
-
if (isMainAgentEntry(parsed) && !parsed.inProgress) {
|
|
233
|
-
const cached = extractCachedContent(parsed);
|
|
234
|
-
if (cached) {
|
|
235
|
-
sendEventToClients(clients, 'kv_cache_content', cached);
|
|
236
|
-
}
|
|
237
|
-
const usage = parsed.response?.body?.usage;
|
|
238
|
-
if (usage) {
|
|
239
|
-
const contextSize = getContextSizeForModel(parsed.body?.model);
|
|
240
|
-
const cwData = buildContextWindowEvent(usage, contextSize);
|
|
241
|
-
if (cwData) {
|
|
242
|
-
sendEventToClients(clients, 'context_window', cwData);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
} catch (err) {
|
|
247
|
-
// Skip invalid entries
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
notifyStatsWorker(logFile);
|
|
251
|
-
}
|
|
326
|
+
fileState.safetyTimer = setInterval(() => {
|
|
327
|
+
_readDelta(fileState);
|
|
328
|
+
}, SAFETY_POLL_MS);
|
|
329
|
+
}
|
|
252
330
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const data = JSON.stringify(segment);
|
|
267
|
-
clients.forEach(client => {
|
|
268
|
-
try { client.write(`event: load_chunk\ndata: ${data}\n\n`); } catch { }
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
clients.forEach(client => {
|
|
272
|
-
try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
|
|
273
|
-
});
|
|
274
|
-
watchLogFile({ ...opts, logFile: currentLogFile });
|
|
275
|
-
}
|
|
276
|
-
} catch (err) {
|
|
277
|
-
// File not yet created, will retry on next poll
|
|
278
|
-
}
|
|
279
|
-
});
|
|
331
|
+
export function unwatchLogFile(logFile) {
|
|
332
|
+
_unwatchSingleFile(logFile);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function unwatchAll() {
|
|
336
|
+
for (const logFile of watchedFiles.keys()) {
|
|
337
|
+
_unwatchSingleFile(logFile);
|
|
338
|
+
}
|
|
339
|
+
for (const [, entry] of _dirWatchers) {
|
|
340
|
+
try { entry.watcher.close(); } catch {}
|
|
341
|
+
}
|
|
342
|
+
_dirWatchers.clear();
|
|
343
|
+
watchedFiles.clear();
|
|
280
344
|
}
|
|
281
345
|
|
|
282
|
-
/**
|
|
283
|
-
* Start watching the current log file + install statusLine + context window.
|
|
284
|
-
* @param {object} opts
|
|
285
|
-
* @param {string} opts.logFile - current LOG_FILE
|
|
286
|
-
* @param {Array} opts.clients - SSE clients array
|
|
287
|
-
* @param {Function} opts.getClaudePid
|
|
288
|
-
* @param {Function} opts.runParallelHook
|
|
289
|
-
* @param {Function} opts.notifyStatsWorker
|
|
290
|
-
* @param {Function} opts.getLogFile
|
|
291
|
-
*/
|
|
292
346
|
export function startWatching(opts) {
|
|
293
347
|
const { clients, ...watchOpts } = opts;
|
|
294
348
|
watchLogFile({ ...watchOpts, clients });
|
|
295
349
|
}
|
|
296
350
|
|
|
297
|
-
/** Get the watchedFiles Map (for cleanup in stopViewer). */
|
|
298
351
|
export function getWatchedFiles() {
|
|
299
352
|
return watchedFiles;
|
|
300
353
|
}
|
|
@@ -75,7 +75,7 @@ export async function installPluginFromUrl(pluginsDir, fileUrl, extractNameScrip
|
|
|
75
75
|
writeFileSync(tmpFile, content, 'utf-8');
|
|
76
76
|
try {
|
|
77
77
|
const result = await new Promise((resolve, reject) => {
|
|
78
|
-
execFile('node', [extractNameScript, tmpFile], { timeout: 5000 }, (err, stdout) => {
|
|
78
|
+
execFile('node', [extractNameScript, tmpFile], { timeout: 5000, windowsHide: true }, (err, stdout) => {
|
|
79
79
|
if (err) return reject(err);
|
|
80
80
|
resolve(stdout);
|
|
81
81
|
});
|