cc-viewer 1.6.335 → 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/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-Dy14VaBK.js"></script>
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.335",
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",
@@ -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
- const result = rotateLogFile(LOG_FILE, filePath, MAX_LOG_SIZE);
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 an empty new file (no content migration) and appends '\n' to old file
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
- // 不迁移旧内容,创建空新文件(立即创建,避免 watcher 时序窗口)
460
- try { writeFileSync(newFile, ''); } catch { }
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
  // 重置成几条切片,比丢掉这条(内容已被更新条目取代)破坏大得多
@@ -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
- _unwatchSingleFile(logFile);
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
+ }
@@ -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
  ];