cc-viewer 1.6.336 → 1.6.337
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/dist/assets/App-BfmiARIo.js +2 -0
- package/dist/assets/{MdxEditorPanel-DT9NB3u6.js → MdxEditorPanel-D3hz1pbz.js} +1 -1
- package/dist/assets/{Mobile-D3xtAcrS.js → Mobile-CUOTDQ4W.js} +1 -1
- package/dist/assets/{index-BwUaV6nZ.js → index-BxApW8sc.js} +2 -2
- package/dist/assets/seqResourceLoaders-DYlUjR-3.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +76 -3
- package/server/lib/interceptor-core.js +50 -4
- package/server/lib/log-management.js +37 -1
- package/server/lib/log-stream.js +57 -0
- package/server/lib/log-watcher.js +5 -1
- package/server/lib/teammate-detect.js +32 -0
- package/server/routes/logs.js +113 -4
- package/dist/assets/App-Df0rIs0i.js +0 -2
- package/dist/assets/seqResourceLoaders-CYJGeb8r.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-BxApW8sc.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-Be06DOUh.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-C9Tqo5sR.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-C927Ebjk.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.337",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/server/interceptor.js
CHANGED
|
@@ -7,7 +7,7 @@ const _ccvSkipArgs = ['--version', '-v', '--v', '--help', '-h', 'doctor', 'insta
|
|
|
7
7
|
const _ccvSkip = _ccvSkipArgs.includes(process.argv[2]);
|
|
8
8
|
|
|
9
9
|
import './lib/proxy-env.js';
|
|
10
|
-
import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, existsSync, watchFile } from 'node:fs';
|
|
10
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, existsSync, watchFile, openSync, readSync, closeSync } from 'node:fs';
|
|
11
11
|
import { AsyncWriteQueue } from './lib/async-write-queue.js';
|
|
12
12
|
import { renameSyncWithRetry } from './lib/file-api.js';
|
|
13
13
|
import http from 'node:http';
|
|
@@ -16,7 +16,7 @@ import { homedir } from 'node:os';
|
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
import { dirname, join, basename } from 'node:path';
|
|
18
18
|
import { LOG_DIR } from '../findcc.js';
|
|
19
|
-
import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, claimUntaggedLog, logFilePrefix, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg, replaceTopLevelModel, injectOutputConfigEffort, resolveProfileModel } from './lib/interceptor-core.js';
|
|
19
|
+
import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, claimUntaggedLog, logFilePrefix, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg, replaceTopLevelModel, injectOutputConfigEffort, resolveProfileModel, extractAgentSpawnPairs, parseRotationContextHead } from './lib/interceptor-core.js';
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
|
|
@@ -241,6 +241,7 @@ function resolveResumeChoice(choice) {
|
|
|
241
241
|
unlinkSync(tempFile);
|
|
242
242
|
}
|
|
243
243
|
LOG_FILE = recentFile;
|
|
244
|
+
seedSpawnRegistryFromLogHead(LOG_FILE);
|
|
244
245
|
} else {
|
|
245
246
|
// new: 将临时文件 rename 为正式新日志文件名(空文件直接删除)
|
|
246
247
|
const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
|
|
@@ -387,6 +388,7 @@ const _initPromise = (async () => {
|
|
|
387
388
|
// 仅在干净退出时才 rename,SIGKILL 下丢失)。
|
|
388
389
|
if (process.env.CCV_IM_PLATFORM) {
|
|
389
390
|
LOG_FILE = recentLog;
|
|
391
|
+
seedSpawnRegistryFromLogHead(LOG_FILE);
|
|
390
392
|
return;
|
|
391
393
|
}
|
|
392
394
|
// Leader / 普通进程:走 resume 交互流程
|
|
@@ -425,6 +427,11 @@ export function initForWorkspace(projectPath, { forceNew = false } = {}) {
|
|
|
425
427
|
_projectName = projectName;
|
|
426
428
|
_logDir = dir;
|
|
427
429
|
LOG_FILE = recentLog;
|
|
430
|
+
// Same lifecycle as the single-project boot paths: start this workspace's
|
|
431
|
+
// registry fresh, then re-seed from the resumed segment's head sentinel so
|
|
432
|
+
// the next rotation's carry-forward stays complete after a restart/switch.
|
|
433
|
+
_agentSpawnRegistry.clear();
|
|
434
|
+
seedSpawnRegistryFromLogHead(LOG_FILE);
|
|
428
435
|
// workspace 切换后,重读该 workspace 的 active-profile.json(可能和上一个 workspace 不同)
|
|
429
436
|
_loadProxyProfile();
|
|
430
437
|
return { filePath: recentLog, dir, projectName, resumed: true };
|
|
@@ -446,6 +453,8 @@ export function initForWorkspace(projectPath, { forceNew = false } = {}) {
|
|
|
446
453
|
_projectName = projectName;
|
|
447
454
|
_logDir = dir;
|
|
448
455
|
LOG_FILE = filePath;
|
|
456
|
+
// Fresh file for a fresh workspace context — no carried names apply.
|
|
457
|
+
_agentSpawnRegistry.clear();
|
|
449
458
|
_loadProxyProfile(); // 同上
|
|
450
459
|
|
|
451
460
|
return { filePath, dir, projectName, resumed: false };
|
|
@@ -456,12 +465,57 @@ export function resetWorkspace() {
|
|
|
456
465
|
_projectName = '';
|
|
457
466
|
_logDir = '';
|
|
458
467
|
LOG_FILE = '';
|
|
468
|
+
// Workspace context gone: drop carried teammate names — the registry is
|
|
469
|
+
// module-global, and without this a rotation in the NEXT workspace would
|
|
470
|
+
// write a sentinel carrying THIS workspace's names (unbounded growth +
|
|
471
|
+
// cross-workspace leakage into the route's teammateNames snapshot).
|
|
472
|
+
_agentSpawnRegistry.clear();
|
|
459
473
|
_loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
|
|
460
474
|
}
|
|
461
475
|
|
|
462
476
|
// Windows NTFS + Defender 下大文件 I/O 代价远高于 Mac/Linux,降低分割阈值减轻压力
|
|
463
477
|
const MAX_LOG_SIZE = (process.platform === 'win32' ? 150 : 300) * 1024 * 1024;
|
|
464
478
|
|
|
479
|
+
// Agent-spawn registry: prompt-prefix(60) → teammate name, accumulated from
|
|
480
|
+
// mainAgent responses as they are written. Carried into the next segment via
|
|
481
|
+
// the rotation-context sentinel so post-split viewers can still resolve
|
|
482
|
+
// teammate names whose spawn turn lives in the previous file. The pure
|
|
483
|
+
// extraction/parsing lives in interceptor-core.js (unit-tested there).
|
|
484
|
+
const _agentSpawnRegistry = new Map();
|
|
485
|
+
|
|
486
|
+
function collectAgentSpawns(entry) {
|
|
487
|
+
try {
|
|
488
|
+
if (!entry || !entry.mainAgent) return;
|
|
489
|
+
for (const [prefix, name] of extractAgentSpawnPairs(entry.response?.body)) {
|
|
490
|
+
_agentSpawnRegistry.set(prefix, name);
|
|
491
|
+
}
|
|
492
|
+
} catch { }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Boot re-seed: when resuming an existing log whose FIRST frame is a
|
|
496
|
+
// rotation-context sentinel, load its carried names so a restarted leader
|
|
497
|
+
// still writes a complete sentinel at the next rotation. Bounded read of the
|
|
498
|
+
// file head only — never a whole-segment scan (segments reach 300MB).
|
|
499
|
+
function seedSpawnRegistryFromLogHead(file) {
|
|
500
|
+
try {
|
|
501
|
+
if (!file || !existsSync(file)) return;
|
|
502
|
+
const fd = openSync(file, 'r');
|
|
503
|
+
let head;
|
|
504
|
+
try {
|
|
505
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
506
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
507
|
+
head = buf.toString('utf-8', 0, n);
|
|
508
|
+
} finally {
|
|
509
|
+
closeSync(fd);
|
|
510
|
+
}
|
|
511
|
+
const entry = parseRotationContextHead(head);
|
|
512
|
+
if (!entry || !Array.isArray(entry.teammateNames)) return;
|
|
513
|
+
for (const pair of entry.teammateNames) {
|
|
514
|
+
if (Array.isArray(pair) && pair[0] && pair[1]) _agentSpawnRegistry.set(pair[0], pair[1]);
|
|
515
|
+
}
|
|
516
|
+
} catch { }
|
|
517
|
+
}
|
|
518
|
+
|
|
465
519
|
async function checkAndRotateLogFile() {
|
|
466
520
|
// Teammate 不做日志轮转,由 leader 负责
|
|
467
521
|
if (_isTeammate) return;
|
|
@@ -470,7 +524,17 @@ async function checkAndRotateLogFile() {
|
|
|
470
524
|
} catch { return; }
|
|
471
525
|
await _writeQueue.flush();
|
|
472
526
|
const { filePath } = generateNewLogFilePath();
|
|
473
|
-
|
|
527
|
+
// Sentinel is baked into the new file's CREATION (rotateLogFile initial
|
|
528
|
+
// content): queueing it after the fact races the watcher's rotation-follow
|
|
529
|
+
// and can leave it undelivered. Synthetic url gives it a stable dedup key.
|
|
530
|
+
const sentinel = JSON.stringify({
|
|
531
|
+
ccvRotationContext: 1,
|
|
532
|
+
url: 'ccv://rotation-context',
|
|
533
|
+
from: basename(LOG_FILE),
|
|
534
|
+
teammateNames: [..._agentSpawnRegistry.entries()],
|
|
535
|
+
timestamp: new Date().toISOString(),
|
|
536
|
+
}) + '\n---\n';
|
|
537
|
+
const result = rotateLogFile(LOG_FILE, filePath, MAX_LOG_SIZE, sentinel);
|
|
474
538
|
if (result.rotated) {
|
|
475
539
|
LOG_FILE = result.newFile;
|
|
476
540
|
// 重置 delta 状态,强制下一条 mainAgent 请求写完整 checkpoint
|
|
@@ -482,6 +546,12 @@ async function checkAndRotateLogFile() {
|
|
|
482
546
|
}
|
|
483
547
|
}
|
|
484
548
|
|
|
549
|
+
// Exposed for the /api/prev-segment-teammates route (belt-and-braces seed
|
|
550
|
+
// delivery when the in-band sentinel falls outside the client's load window).
|
|
551
|
+
export function getAgentSpawnRegistrySnapshot() {
|
|
552
|
+
return [..._agentSpawnRegistry.entries()];
|
|
553
|
+
}
|
|
554
|
+
|
|
485
555
|
// 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
|
|
486
556
|
function getBaseUrlHost() {
|
|
487
557
|
try {
|
|
@@ -1014,6 +1084,8 @@ export function setupInterceptor() {
|
|
|
1014
1084
|
// 直接使用组装后的 message 对象作为 response.body
|
|
1015
1085
|
// 如果组装失败(例如非标准 SSE),则使用原始流内容
|
|
1016
1086
|
requestEntry.response.body = assembledMessage || fullContent;
|
|
1087
|
+
// Must run before the post-write `response = null` release.
|
|
1088
|
+
collectAgentSpawns(requestEntry);
|
|
1017
1089
|
|
|
1018
1090
|
|
|
1019
1091
|
// 移除在途请求标记,保持原始报文
|
|
@@ -1139,6 +1211,7 @@ export function setupInterceptor() {
|
|
|
1139
1211
|
body: responseData
|
|
1140
1212
|
};
|
|
1141
1213
|
|
|
1214
|
+
collectAgentSpawns(requestEntry);
|
|
1142
1215
|
|
|
1143
1216
|
delete requestEntry.inProgress;
|
|
1144
1217
|
delete requestEntry.requestId;
|
|
@@ -15,6 +15,44 @@ const SUBAGENT_BILLING_RE = /cc_is_subagent=true\b/;
|
|
|
15
15
|
// 两处服务端实现(本文件 + kv-cache-analyzer)由 test/interceptor-core-mainagent.test.js 互校防漂移;
|
|
16
16
|
// 前端 contentFilter 那份由 test/content-filter-unit.test.js 单测覆盖。
|
|
17
17
|
const TEAMMATE_SYSTEM_RE = /running as an agent in a team|Agent Teammate Communication/i;
|
|
18
|
+
// Exported for server/lib/teammate-detect.js (previous-segment backfill filter).
|
|
19
|
+
export { TEAMMATE_SYSTEM_RE, SUBAGENT_BILLING_RE };
|
|
20
|
+
|
|
21
|
+
// Rotation carry-forward: prompt-prefix → teammate-name pairs extracted from a
|
|
22
|
+
// mainAgent response body's Agent tool_use blocks. Prefix normalization MUST
|
|
23
|
+
// match src/utils/contentFilter.js (trimStart BEFORE slice, length 60) —
|
|
24
|
+
// pinned by test/interceptor.test.js parity cases.
|
|
25
|
+
export const TEAMMATE_PROMPT_PREFIX_LEN = 60;
|
|
26
|
+
|
|
27
|
+
export function extractAgentSpawnPairs(responseBody) {
|
|
28
|
+
const pairs = [];
|
|
29
|
+
// The interceptor's stream path can fall back to a raw string body.
|
|
30
|
+
if (!responseBody || typeof responseBody !== 'object') return pairs;
|
|
31
|
+
const content = responseBody.content;
|
|
32
|
+
if (!Array.isArray(content)) return pairs;
|
|
33
|
+
for (const block of content) {
|
|
34
|
+
if (!block || block.type !== 'tool_use' || block.name !== 'Agent') continue;
|
|
35
|
+
const inp = block.input;
|
|
36
|
+
if (!inp || !inp.name || typeof inp.prompt !== 'string') continue;
|
|
37
|
+
const prefix = inp.prompt.trimStart().slice(0, TEAMMATE_PROMPT_PREFIX_LEN);
|
|
38
|
+
if (prefix) pairs.push([prefix, inp.name]);
|
|
39
|
+
}
|
|
40
|
+
return pairs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parses the first frame of a log-file head string; returns the entry when it
|
|
44
|
+
// is a rotation-context sentinel, else null. Callers pass a BOUNDED head read
|
|
45
|
+
// (never a whole segment).
|
|
46
|
+
export function parseRotationContextHead(headString) {
|
|
47
|
+
try {
|
|
48
|
+
const frameEnd = headString.indexOf('\n---\n');
|
|
49
|
+
if (frameEnd <= 0) return null;
|
|
50
|
+
const entry = JSON.parse(headString.slice(0, frameEnd));
|
|
51
|
+
return entry && entry.ccvRotationContext ? entry : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
18
56
|
|
|
19
57
|
export function getSystemText(body) {
|
|
20
58
|
const system = body?.system;
|
|
@@ -443,21 +481,29 @@ export function fingerprintMsg(m) {
|
|
|
443
481
|
|
|
444
482
|
/**
|
|
445
483
|
* Rotate log file when it exceeds maxSize.
|
|
446
|
-
* Creates
|
|
484
|
+
* Creates a new file (no content migration) and appends '\n' to old file
|
|
447
485
|
* to trigger fs.watchFile callback for watcher migration.
|
|
448
486
|
*
|
|
487
|
+
* initialContent (optional) is written INTO the new file at creation time —
|
|
488
|
+
* used for the rotation-context sentinel. It must be baked into creation
|
|
489
|
+
* rather than queued afterwards: the old-file trigger byte below fires the
|
|
490
|
+
* watcher's rotation-follow after a short debounce, and a late-flushing
|
|
491
|
+
* queued write can land between the new file's initial stream read and its
|
|
492
|
+
* lastByteOffset snapshot, in which case it is never delivered to clients.
|
|
493
|
+
*
|
|
449
494
|
* @param {string} currentFile - current log file path
|
|
450
495
|
* @param {string} newFile - new log file path to rotate to
|
|
451
496
|
* @param {number} maxSize - max file size in bytes
|
|
497
|
+
* @param {string} [initialContent] - content the new file is created with
|
|
452
498
|
* @returns {{ rotated: boolean, oldFile?: string, newFile?: string }}
|
|
453
499
|
*/
|
|
454
|
-
export function rotateLogFile(currentFile, newFile, maxSize) {
|
|
500
|
+
export function rotateLogFile(currentFile, newFile, maxSize, initialContent = '') {
|
|
455
501
|
try {
|
|
456
502
|
if (!existsSync(currentFile)) return { rotated: false };
|
|
457
503
|
const size = statSync(currentFile).size;
|
|
458
504
|
if (size < maxSize) return { rotated: false };
|
|
459
|
-
//
|
|
460
|
-
try { writeFileSync(newFile,
|
|
505
|
+
// 不迁移旧内容,创建新文件(立即创建,避免 watcher 时序窗口)
|
|
506
|
+
try { writeFileSync(newFile, initialContent); } catch { }
|
|
461
507
|
// 触发旧文件 watcher 回调,使其检测到文件变更并切换到新文件
|
|
462
508
|
try { appendFileSync(currentFile, '\n'); } catch { }
|
|
463
509
|
return { rotated: true, oldFile: currentFile, newFile };
|
|
@@ -2,7 +2,7 @@ import { existsSync, realpathSync, unlinkSync, readdirSync, readFileSync, writeF
|
|
|
2
2
|
import { readFile, writeFile, appendFile, stat, readdir } from 'node:fs/promises';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
import { renameSyncWithRetry } from './file-api.js';
|
|
5
|
-
import { join, sep } from 'node:path';
|
|
5
|
+
import { join, sep, dirname, basename } from 'node:path';
|
|
6
6
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
7
7
|
import { streamReconstructedEntriesAsync } from './log-stream.js';
|
|
8
8
|
import { archiveJsonl, resolveJsonlPath } from './jsonl-archive.js';
|
|
@@ -97,6 +97,39 @@ export async function listLocalLogs(logDir, currentProjectName, { instanceId = n
|
|
|
97
97
|
return { ...grouped, _currentProject: currentProjectName || '' };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Locate the log segment immediately preceding currentFile in the same
|
|
102
|
+
* directory, for the post-rotation teammate backfill. Ownership is enforced
|
|
103
|
+
* via logFileMatcher(projectName, instanceId) — a naive "older file in the
|
|
104
|
+
* same dir" scan would cross --pid instances and return another
|
|
105
|
+
* conversation's log. Archived (.jsonl.zip) predecessors are valid results:
|
|
106
|
+
* readers resolve them transparently via resolveJsonlPath. Returns the
|
|
107
|
+
* absolute path, or null when there is no strictly-older owned segment.
|
|
108
|
+
*/
|
|
109
|
+
export function findPreviousSegment(currentFile, projectName, instanceId = null) {
|
|
110
|
+
try {
|
|
111
|
+
if (!currentFile || !projectName) return null;
|
|
112
|
+
const dir = dirname(currentFile);
|
|
113
|
+
const currentTs = parseLogTs(basename(currentFile));
|
|
114
|
+
if (!currentTs || !existsSync(dir)) return null;
|
|
115
|
+
const owns = logFileMatcher(projectName, instanceId);
|
|
116
|
+
let best = null;
|
|
117
|
+
let bestTs = '';
|
|
118
|
+
for (const f of readdirSync(dir)) {
|
|
119
|
+
if (!isLogFileName(f) || !owns(f)) continue;
|
|
120
|
+
const ts = parseLogTs(f);
|
|
121
|
+
if (!ts || ts >= currentTs) continue;
|
|
122
|
+
if (ts > bestTs || (ts === bestTs && best !== null && f > basename(best))) {
|
|
123
|
+
best = join(dir, f);
|
|
124
|
+
bestTs = ts;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
100
133
|
export async function readLocalLog(logDir, file) {
|
|
101
134
|
validateLogPath(logDir, file);
|
|
102
135
|
const filePath = resolveJsonlPath(join(logDir, file));
|
|
@@ -190,6 +223,9 @@ export async function mergeLogFiles(logDir, files) {
|
|
|
190
223
|
await streamReconstructedEntriesAsync(filePath, async (segment) => {
|
|
191
224
|
let chunk = '';
|
|
192
225
|
for (const entry of segment) {
|
|
226
|
+
// Rotation-context sentinels describe a predecessor relationship that a
|
|
227
|
+
// merged file no longer has — drop them from the merge product.
|
|
228
|
+
if (entry.ccvRotationContext) continue;
|
|
193
229
|
// 乱序/断裂条目若未被补偿回填(messages 仍是裸 delta 切片),丢弃不写:
|
|
194
230
|
// 剥除 _deltaFormat 后它会伪装成旧格式全量条目,未来读取时把累积状态
|
|
195
231
|
// 重置成几条切片,比丢掉这条(内容已被更新条目取代)破坏大得多
|
package/server/lib/log-stream.js
CHANGED
|
@@ -511,3 +511,60 @@ export async function readPagedEntries(filePath, { before, limit }) {
|
|
|
511
511
|
const oldestTimestamp = extractTimestamp(sliced[0]) || '';
|
|
512
512
|
return { entries: sliced, hasMore, oldestTimestamp, count: sliced.length };
|
|
513
513
|
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Scan a whole segment and collect the parsed entries matching `predicate`,
|
|
517
|
+
* for the post-rotation teammate backfill (/api/prev-segment-teammates).
|
|
518
|
+
*
|
|
519
|
+
* - `rawPrefilter(rawString)`: cheap substring gate evaluated BEFORE
|
|
520
|
+
* JSON.parse, so multi-MB MainAgent checkpoint frames are skipped without
|
|
521
|
+
* parsing.
|
|
522
|
+
* - Last-write-wins dedup by timestamp|url (a request's inProgress and final
|
|
523
|
+
* writes share one key; the later frame replaces the earlier).
|
|
524
|
+
* - `maxBytes` budget applied NEWEST-first over the raw sizes: when the
|
|
525
|
+
* matches exceed the budget, the oldest are dropped and `truncated` is set.
|
|
526
|
+
*
|
|
527
|
+
* Returns { entries, truncated } with entries in chronological (file) order.
|
|
528
|
+
*/
|
|
529
|
+
export async function collectFilteredRawEntriesAsync(filePath, predicate, {
|
|
530
|
+
maxBytes = 64 * 1024 * 1024,
|
|
531
|
+
rawPrefilter = null,
|
|
532
|
+
yieldEvery = 50,
|
|
533
|
+
} = {}) {
|
|
534
|
+
const matched = new Map(); // dedupKey -> { size, entry }, insertion order = file order
|
|
535
|
+
let nokey = 0;
|
|
536
|
+
let totalBytes = 0;
|
|
537
|
+
let truncated = false;
|
|
538
|
+
let parsedCount = 0;
|
|
539
|
+
for await (const raw of iterateRawEntriesAsync(filePath)) {
|
|
540
|
+
if (rawPrefilter && !rawPrefilter(raw)) continue;
|
|
541
|
+
// Real team logs defeat the prefilter for most BYTES (leader checkpoints
|
|
542
|
+
// embed the SendMessage tool text), so parses can total >1s on a 300MB
|
|
543
|
+
// segment — yield periodically to keep the live proxy's event loop
|
|
544
|
+
// responsive during the scan.
|
|
545
|
+
if (yieldEvery > 0 && ++parsedCount % yieldEvery === 0) {
|
|
546
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
547
|
+
}
|
|
548
|
+
let entry;
|
|
549
|
+
try { entry = JSON.parse(raw); } catch { continue; }
|
|
550
|
+
if (!entry || !predicate(entry)) continue;
|
|
551
|
+
const key = extractDedupKey(raw) || `__nokey_${nokey++}`;
|
|
552
|
+
const prev = matched.get(key);
|
|
553
|
+
if (prev) totalBytes -= prev.size;
|
|
554
|
+
matched.set(key, { size: raw.length, entry });
|
|
555
|
+
totalBytes += raw.length;
|
|
556
|
+
// Enforce the budget DURING the scan (newest wins): evicting the oldest
|
|
557
|
+
// keys keeps transient memory bounded even on teammate-heavy segments —
|
|
558
|
+
// not just the response size.
|
|
559
|
+
while (totalBytes > maxBytes && matched.size > 1) {
|
|
560
|
+
const oldestKey = matched.keys().next().value;
|
|
561
|
+
totalBytes -= matched.get(oldestKey).size;
|
|
562
|
+
matched.delete(oldestKey);
|
|
563
|
+
truncated = true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
entries: [...matched.values()].map((it) => it.entry),
|
|
568
|
+
truncated,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
@@ -115,7 +115,11 @@ export function sendChunkToClients(clients, dataJson) {
|
|
|
115
115
|
// --- 轮转切换(抽取公共逻辑) ---
|
|
116
116
|
|
|
117
117
|
async function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
|
|
118
|
-
|
|
118
|
+
// Deliberately KEEP the old file watched: external-process teammates resolve
|
|
119
|
+
// the leader log once at boot and continue appending to the previous segment
|
|
120
|
+
// after rotation. Unwatching it would make their post-rotation entries
|
|
121
|
+
// invisible live; client dedup absorbs any replayed frames. watchedFiles is
|
|
122
|
+
// a multi-file Map, so watching both segments is supported.
|
|
119
123
|
const total = await countLogEntries(currentLogFile);
|
|
120
124
|
sendEventToClients(clients, 'load_start', { total, incremental: false });
|
|
121
125
|
await streamReconstructedEntriesAsync(currentLogFile, (segment) => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getSystemText, TEAMMATE_SYSTEM_RE } from './interceptor-core.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-side teammate detection for the previous-segment backfill filter
|
|
5
|
+
* (/api/prev-segment-teammates). Mirrors the client's three teammate shapes:
|
|
6
|
+
*
|
|
7
|
+
* 1. External-process teammate — interceptor persisted `entry.teammate`
|
|
8
|
+
* (--agent-name / --parent-session-id spawn).
|
|
9
|
+
* 2. Proxy-mode teammate — system prompt carries the team-communication
|
|
10
|
+
* marker (TEAMMATE_SYSTEM_RE, shared with interceptor-core.js).
|
|
11
|
+
* 3. Native (same-process Agent-tool) teammate — SDK agent system prompt
|
|
12
|
+
* ("You are a Claude agent", NOT "You are Claude Code") plus a
|
|
13
|
+
* SendMessage tool; plain subagents are never granted SendMessage.
|
|
14
|
+
*
|
|
15
|
+
* KEEP IN SYNC with src/utils/teammateDetector.js (native rule) and
|
|
16
|
+
* src/utils/contentFilter.js (TEAMMATE_SYSTEM_RE). Drift is pinned by the
|
|
17
|
+
* parity cases in test/teammate-detect.test.js (pattern follows
|
|
18
|
+
* test/interceptor-core-mainagent.test.js).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Mirror of src/utils/teammateDetector.js NATIVE_TEAMMATE_RE.
|
|
22
|
+
const NATIVE_TEAMMATE_RE = /You are a Claude agent/i;
|
|
23
|
+
|
|
24
|
+
export function isTeammateLikeEntry(entry) {
|
|
25
|
+
if (!entry || !entry.body) return false;
|
|
26
|
+
if (entry.teammate) return true;
|
|
27
|
+
const sysText = getSystemText(entry.body);
|
|
28
|
+
if (TEAMMATE_SYSTEM_RE.test(sysText)) return true;
|
|
29
|
+
if (!NATIVE_TEAMMATE_RE.test(sysText)) return false;
|
|
30
|
+
const tools = entry.body.tools;
|
|
31
|
+
return Array.isArray(tools) && tools.some((t) => t && t.name === 'SendMessage');
|
|
32
|
+
}
|
package/server/routes/logs.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Local log management routes (moved verbatim from server.js handleRequest).
|
|
2
|
-
import { existsSync, realpathSync, statSync, createReadStream } from 'node:fs';
|
|
2
|
+
import { existsSync, realpathSync, statSync, createReadStream, openSync, readSync, closeSync } from 'node:fs';
|
|
3
3
|
import { join, basename } from 'node:path';
|
|
4
4
|
import { LOG_DIR } from '../../findcc.js';
|
|
5
|
-
import { _projectName } from '../interceptor.js';
|
|
6
|
-
import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles, validateLogPath } from '../lib/log-management.js';
|
|
7
|
-
import { countLogEntries, streamRawEntriesAsync, readTailEntries } from '../lib/log-stream.js';
|
|
5
|
+
import { _projectName, LOG_FILE, getAgentSpawnRegistrySnapshot } from '../interceptor.js';
|
|
6
|
+
import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles, validateLogPath, findPreviousSegment } from '../lib/log-management.js';
|
|
7
|
+
import { countLogEntries, streamRawEntriesAsync, readTailEntries, collectFilteredRawEntriesAsync } from '../lib/log-stream.js';
|
|
8
|
+
import { isTeammateLikeEntry } from '../lib/teammate-detect.js';
|
|
8
9
|
|
|
9
10
|
async function localLogs(req, res, parsedUrl, isLocal, deps) {
|
|
10
11
|
try {
|
|
@@ -207,6 +208,113 @@ function archiveLogs(req, res, parsedUrl, isLocal, deps) {
|
|
|
207
208
|
});
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
// Bounded head read: returns the parsed first frame of a live log file when it
|
|
212
|
+
// is a rotation-context sentinel, else null. Never scans past the first frame.
|
|
213
|
+
function readHeadRotationContext(file) {
|
|
214
|
+
try {
|
|
215
|
+
if (!file || !existsSync(file)) return null;
|
|
216
|
+
const fd = openSync(file, 'r');
|
|
217
|
+
let head;
|
|
218
|
+
try {
|
|
219
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
220
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
221
|
+
head = buf.toString('utf-8', 0, n);
|
|
222
|
+
} finally {
|
|
223
|
+
closeSync(fd);
|
|
224
|
+
}
|
|
225
|
+
const frameEnd = head.indexOf('\n---\n');
|
|
226
|
+
if (frameEnd <= 0) return null;
|
|
227
|
+
const entry = JSON.parse(head.slice(0, frameEnd));
|
|
228
|
+
return entry && entry.ccvRotationContext ? entry : null;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Cheap substring gate evaluated before JSON.parse: every teammate shape
|
|
235
|
+
// carries at least one of these markers in its raw JSON (external tag,
|
|
236
|
+
// proxy-mode system marker, native SDK prompt). Giant MainAgent checkpoint
|
|
237
|
+
// frames are skipped without parsing.
|
|
238
|
+
function teammateRawPrefilter(raw) {
|
|
239
|
+
return raw.includes('"teammate"')
|
|
240
|
+
|| raw.includes('agent in a team')
|
|
241
|
+
|| raw.includes('Agent Teammate Communication')
|
|
242
|
+
|| raw.includes('You are a Claude agent');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Single-flight + short-TTL cache for the previous-segment scan. A real
|
|
246
|
+
// predecessor is ~300MB and the parse pass transiently costs hundreds of MB of
|
|
247
|
+
// RSS; every non-incremental cold load triggers the route, so concurrent
|
|
248
|
+
// viewers / rapid refreshes must share one scan. Keyed by path+size+mtime —
|
|
249
|
+
// the old segment can still GROW (external teammates keep appending to it),
|
|
250
|
+
// which changes the key and forces a rescan.
|
|
251
|
+
const PREV_SCAN_TTL_MS = 60 * 1000;
|
|
252
|
+
let _prevScanCache = null; // { key, promise, expiresAt }
|
|
253
|
+
|
|
254
|
+
function collectPrevSegmentCached(prev, predicate, opts) {
|
|
255
|
+
let st = null;
|
|
256
|
+
try { st = statSync(prev); } catch { }
|
|
257
|
+
const key = `${prev}|${st ? st.size : 0}|${st ? st.mtimeMs : 0}`;
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
if (_prevScanCache && _prevScanCache.key === key && _prevScanCache.expiresAt > now) {
|
|
260
|
+
return _prevScanCache.promise;
|
|
261
|
+
}
|
|
262
|
+
const promise = collectFilteredRawEntriesAsync(prev, predicate, opts);
|
|
263
|
+
_prevScanCache = { key, promise, expiresAt: now + PREV_SCAN_TTL_MS };
|
|
264
|
+
promise.catch(() => {
|
|
265
|
+
if (_prevScanCache && _prevScanCache.promise === promise) _prevScanCache = null;
|
|
266
|
+
});
|
|
267
|
+
return promise;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Post-rotation teammate backfill. Resolves the previous segment of the
|
|
272
|
+
* server's OWN live LOG_FILE (no client-supplied filenames — no traversal
|
|
273
|
+
* surface) and streams NDJSON:
|
|
274
|
+
* line 1: { rotationContext, teammateNames } (context; teammateNames merges
|
|
275
|
+
* the head sentinel's carry-forward with the in-process spawn
|
|
276
|
+
* registry — the sentinel may sit outside the client's load window)
|
|
277
|
+
* lines: one backfill entry per line (teammate-like, renderable only)
|
|
278
|
+
* last: { done: true, truncated, prevSegment }
|
|
279
|
+
*/
|
|
280
|
+
async function prevSegmentTeammates(req, res, parsedUrl, isLocal, deps) {
|
|
281
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
|
|
282
|
+
try {
|
|
283
|
+
const current = LOG_FILE;
|
|
284
|
+
const sentinel = readHeadRotationContext(current);
|
|
285
|
+
const names = new Map(sentinel?.teammateNames?.filter((p) => Array.isArray(p) && p[0]) || []);
|
|
286
|
+
for (const [prefix, name] of getAgentSpawnRegistrySnapshot()) names.set(prefix, name);
|
|
287
|
+
res.write(JSON.stringify({
|
|
288
|
+
rotationContext: sentinel ? { from: sentinel.from || null } : null,
|
|
289
|
+
teammateNames: [...names.entries()],
|
|
290
|
+
}) + '\n');
|
|
291
|
+
// No live file (workspace pre-select) or resume placeholder → context only.
|
|
292
|
+
if (!current || current.endsWith('_temp.jsonl')) {
|
|
293
|
+
res.end(JSON.stringify({ done: true, truncated: false, prevSegment: null }) + '\n');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const instanceId = (deps && deps.instanceId) || null;
|
|
297
|
+
const prev = findPreviousSegment(current, _projectName, instanceId);
|
|
298
|
+
if (!prev) {
|
|
299
|
+
res.end(JSON.stringify({ done: true, truncated: false, prevSegment: null }) + '\n');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const { entries, truncated } = await collectPrevSegmentCached(prev, (entry) => (
|
|
303
|
+
!entry.inProgress && !entry.isHeartbeat && !entry.isCountTokens
|
|
304
|
+
&& !entry.ccvRotationContext
|
|
305
|
+
&& !(entry._deltaFormat && entry.mainAgent && !entry.teammate)
|
|
306
|
+
&& !!entry.timestamp
|
|
307
|
+
&& Array.isArray(entry.response?.body?.content) && entry.response.body.content.length > 0
|
|
308
|
+
&& isTeammateLikeEntry(entry)
|
|
309
|
+
), { rawPrefilter: teammateRawPrefilter });
|
|
310
|
+
for (const entry of entries) res.write(JSON.stringify(entry) + '\n');
|
|
311
|
+
res.end(JSON.stringify({ done: true, truncated, prevSegment: basename(prev) }) + '\n');
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// Headers already sent — terminate the NDJSON stream with an error line.
|
|
314
|
+
try { res.end(JSON.stringify({ done: true, error: err?.message || 'internal error' }) + '\n'); } catch { }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
210
318
|
export const logsRoutes = [
|
|
211
319
|
{ method: 'GET', match: 'exact', path: '/api/local-logs', handler: localLogs },
|
|
212
320
|
{ method: 'GET', match: 'exact', path: '/api/download-log', handler: downloadLog },
|
|
@@ -214,4 +322,5 @@ export const logsRoutes = [
|
|
|
214
322
|
{ method: 'POST', match: 'exact', path: '/api/delete-logs', handler: deleteLogs },
|
|
215
323
|
{ method: 'POST', match: 'exact', path: '/api/merge-logs', handler: mergeLogs },
|
|
216
324
|
{ method: 'POST', match: 'exact', path: '/api/archive-logs', handler: archiveLogs },
|
|
325
|
+
{ method: 'GET', match: 'exact', path: '/api/prev-segment-teammates', handler: prevSegmentTeammates },
|
|
217
326
|
];
|