cc-viewer 1.5.45 → 1.6.0

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
@@ -6,7 +6,7 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-CjzDSoiD.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-D0IRVteu.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-BawBkbaU.css">
11
11
  </head>
12
12
  <body>
package/interceptor.js CHANGED
@@ -80,6 +80,19 @@ function resolveResumeChoice(choice) {
80
80
  return result;
81
81
  }
82
82
 
83
+ // Teammate 子进程检测:teammate 通过 --parent-session-id 启动(提前到日志路径初始化之前)
84
+ const _isTeammate = process.argv.includes('--parent-session-id');
85
+ // 提取 teammate 元数据(--agent-name worker-1 --team-name fix-ts-errors)
86
+ let _teammateName = null;
87
+ let _teamName = null;
88
+ if (_isTeammate) {
89
+ const args = process.argv;
90
+ const nameIdx = args.indexOf('--agent-name');
91
+ if (nameIdx !== -1 && nameIdx + 1 < args.length) _teammateName = args[nameIdx + 1];
92
+ const teamIdx = args.indexOf('--team-name');
93
+ if (teamIdx !== -1 && teamIdx + 1 < args.length) _teamName = args[teamIdx + 1];
94
+ }
95
+
83
96
  // 初始化日志文件路径(异步,支持用户交互)
84
97
  // 工作区模式下延迟到选择工作区后再初始化
85
98
  let _newLogFile, _logDir, _projectName;
@@ -87,6 +100,14 @@ if (process.env.CCV_WORKSPACE_MODE === '1') {
87
100
  _newLogFile = '';
88
101
  _logDir = '';
89
102
  _projectName = '';
103
+ } else if (_isTeammate) {
104
+ // Teammate 子进程:只需 projectName 和 logDir 来查找 leader 日志,不生成新文件路径
105
+ let cwd;
106
+ try { cwd = process.cwd(); } catch { cwd = homedir(); }
107
+ _projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
108
+ _logDir = join(LOG_DIR, _projectName);
109
+ const _leaderLog = findRecentLog(_logDir, _projectName);
110
+ _newLogFile = _leaderLog || ''; // 没有 leader 日志时不写入
90
111
  } else {
91
112
  ({ filePath: _newLogFile, dir: _logDir, projectName: _projectName } = generateNewLogFilePath());
92
113
  // 启动时清理残留临时文件
@@ -96,10 +117,11 @@ let LOG_FILE = _newLogFile;
96
117
 
97
118
  const _initPromise = (async () => {
98
119
  if (!_logDir || !_projectName) return; // 工作区模式下跳过
120
+ if (_isTeammate) return; // Teammate 已在上方同步初始化,跳过 async resume 流程
99
121
  try {
100
122
  const recentLog = findRecentLog(_logDir, _projectName);
101
123
  if (recentLog) {
102
- // 始终复用最新日志文件
124
+ // Leader / 普通进程:走 resume 交互流程
103
125
  const tempFile = _newLogFile.replace('.jsonl', '_temp.jsonl');
104
126
  LOG_FILE = tempFile;
105
127
  _resumeState = {
@@ -195,17 +217,20 @@ export function setupInterceptor() {
195
217
  globalThis._ccViewerInterceptorInstalled = true;
196
218
 
197
219
  // 启动 viewer 服务(优先根目录 server.js,fallback 到 lib/server.js)
198
- const rootServerPath = join(__dirname, 'server.js');
199
- const libServerPath = join(__dirname, 'lib', 'server.js');
200
- import(rootServerPath).then(module => {
201
- viewerModule = module;
202
- }).catch(() => {
203
- import(libServerPath).then(module => {
220
+ // Teammate 子进程跳过,避免端口冲突(leader 已启动 viewer)
221
+ if (!_isTeammate) {
222
+ const rootServerPath = join(__dirname, 'server.js');
223
+ const libServerPath = join(__dirname, 'lib', 'server.js');
224
+ import(rootServerPath).then(module => {
204
225
  viewerModule = module;
205
226
  }).catch(() => {
206
- // Silently fail if viewer service cannot start
227
+ import(libServerPath).then(module => {
228
+ viewerModule = module;
229
+ }).catch(() => {
230
+ // Silently fail if viewer service cannot start
231
+ });
207
232
  });
208
- });
233
+ }
209
234
 
210
235
  // 注册退出处理器
211
236
  const cleanupViewer = async () => {
@@ -318,7 +343,8 @@ export function setupInterceptor() {
318
343
  isStream: body?.stream === true,
319
344
  isHeartbeat: /\/api\/eval\/sdk-/.test(urlStr),
320
345
  isCountTokens: /\/messages\/count_tokens/.test(urlStr),
321
- mainAgent: isMainAgentRequest(body)
346
+ mainAgent: isMainAgentRequest(body),
347
+ ...(_isTeammate && { teammate: _teammateName, teamName: _teamName })
322
348
  };
323
349
  }
324
350
  } catch { }
@@ -494,11 +520,13 @@ export function setupInterceptor() {
494
520
  // 自动执行拦截器设置
495
521
  // proxy 模式下(ccv CLI 或 ccv run),外层 proxy.js 已显式调用 setupInterceptor(),
496
522
  // 这里跳过自动执行,避免 Claude 进程中重复拦截 fetch
497
- if (!_ccvSkip && !process.env.CCV_PROXY_MODE) setupInterceptor();
523
+ // Teammate 子进程即使继承了 CCV_PROXY_MODE 也需要启用拦截(它是独立 claude 进程,不走 proxy)
524
+ if (!_ccvSkip && (!process.env.CCV_PROXY_MODE || _isTeammate)) setupInterceptor();
498
525
 
499
526
  // 等待日志文件初始化完成后启动 Web Viewer 服务
500
527
  // 如果是 ccv --c 通过 proxy 模式启动的,外层已有 server,跳过
501
- if (!_ccvSkip && !process.env.CCV_PROXY_MODE) {
528
+ // Teammate 子进程也跳过,避免端口冲突(leader 已启动 viewer)
529
+ if (!_ccvSkip && !process.env.CCV_PROXY_MODE && !_isTeammate) {
502
530
  _initPromise.then(() => import('./server.js')).catch((err) => {
503
531
  console.error('[CC-Viewer] Failed to start viewer server:', err);
504
532
  });
@@ -83,13 +83,17 @@ export function updateContextWindowFromResponse(responseBody, requestBody, model
83
83
  } catch { }
84
84
 
85
85
  // 仅更新 context_window 字段(如果 Claude Code 的 statusLine 也在写,它的数据更准确会覆盖我们的)
86
+ // 保留 Claude Code 写入的 context_window_size(如 1M),仅在不存在时才用推断值
87
+ const existingSize = existing.context_window?.context_window_size;
88
+ const finalSize = (existingSize && existingSize > contextWindowSize) ? existingSize : contextWindowSize;
89
+ const finalPct = Math.round(((inputTokens + outputTokens) / finalSize) * 100);
86
90
  existing.context_window = {
87
91
  total_input_tokens: inputTokens,
88
92
  total_output_tokens: outputTokens,
89
- context_window_size: contextWindowSize,
93
+ context_window_size: finalSize,
90
94
  current_usage: usage,
91
- used_percentage: usedPct,
92
- remaining_percentage: 100 - usedPct,
95
+ used_percentage: finalPct,
96
+ remaining_percentage: 100 - finalPct,
93
97
  };
94
98
 
95
99
  writeFileSync(CONTEXT_WINDOW_FILE, JSON.stringify(existing) + '\n');
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Server-side KV-Cache content analyzer.
3
+ * Ported from src/utils/helpers.js + src/utils/contentFilter.js
4
+ */
5
+
6
+ const SUBAGENT_SYSTEM_RE = /command execution specialist|file search specialist|planning specialist|general-purpose agent/i;
7
+ const TEAMMATE_SYSTEM_RE = /running as an agent in a team|Agent Teammate Communication/i;
8
+
9
+ function getSystemText(body) {
10
+ const system = body?.system;
11
+ if (typeof system === 'string') return system;
12
+ if (Array.isArray(system)) {
13
+ return system.map(s => (s && s.text) || '').join('');
14
+ }
15
+ return '';
16
+ }
17
+
18
+ /**
19
+ * Determine if a log entry is from the MainAgent (not a teammate or subagent).
20
+ */
21
+ export function isMainAgentEntry(entry) {
22
+ if (!entry) return false;
23
+
24
+ // Teammate subprocess requests are not MainAgent
25
+ if (entry.teammate) return false;
26
+ const sysText = getSystemText(entry.body || {});
27
+ if (TEAMMATE_SYSTEM_RE.test(sysText)) return false;
28
+
29
+ if (entry.mainAgent === true) {
30
+ if (SUBAGENT_SYSTEM_RE.test(sysText)) return false;
31
+ return true;
32
+ }
33
+
34
+ // Fallback detection for entries without mainAgent flag
35
+ const body = entry.body || {};
36
+ if (!body.system || !Array.isArray(body.tools)) return false;
37
+
38
+ if (!sysText.includes('You are Claude Code')) return false;
39
+ if (SUBAGENT_SYSTEM_RE.test(sysText)) return false;
40
+
41
+ // New architecture (v2.1.69+): deferred tool loading
42
+ const isSystemArray = Array.isArray(body.system);
43
+ const hasToolSearch = body.tools.some(t => t.name === 'ToolSearch');
44
+ if (isSystemArray && hasToolSearch) {
45
+ const messages = body.messages || [];
46
+ const firstMsgContent = messages.length > 0
47
+ ? (typeof messages[0].content === 'string' ? messages[0].content
48
+ : Array.isArray(messages[0].content) ? messages[0].content.map(c => c.text || '').join('') : '')
49
+ : '';
50
+ if (firstMsgContent.includes('<available-deferred-tools>')) return true;
51
+ }
52
+
53
+ // Old architecture: >10 tools with core tool set
54
+ if (body.tools.length > 10) {
55
+ const hasEdit = body.tools.some(t => t.name === 'Edit');
56
+ const hasBash = body.tools.some(t => t.name === 'Bash');
57
+ const hasTaskOrAgent = body.tools.some(t => t.name === 'Task' || t.name === 'Agent');
58
+ if (hasEdit && hasBash && hasTaskOrAgent) return true;
59
+ }
60
+
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Extract text from a tool_result content block.
66
+ */
67
+ export function extractToolResultText(toolResult) {
68
+ if (!toolResult.content) return String(toolResult.content ?? '');
69
+ if (typeof toolResult.content === 'string') return toolResult.content;
70
+ if (Array.isArray(toolResult.content)) {
71
+ return toolResult.content
72
+ .filter(b => b.type === 'text')
73
+ .map(b => b.text)
74
+ .join('\n');
75
+ }
76
+ return JSON.stringify(toolResult.content);
77
+ }
78
+
79
+ /**
80
+ * Extract cached content from a single MainAgent log entry.
81
+ * Returns null if the entry is not a MainAgent entry.
82
+ */
83
+ export function extractCachedContent(entry) {
84
+ if (!isMainAgentEntry(entry)) return null;
85
+ if (!entry.body) return null;
86
+
87
+ const body = entry.body;
88
+ const usage = entry.response?.body?.usage;
89
+
90
+ const result = {
91
+ system: [],
92
+ messages: [],
93
+ tools: [],
94
+ cacheCreateTokens: usage?.cache_creation_input_tokens || 0,
95
+ cacheReadTokens: usage?.cache_read_input_tokens || 0,
96
+ };
97
+
98
+ // system: find last block with cache_control, collect 0..lastIndex
99
+ if (Array.isArray(body.system)) {
100
+ let lastCacheIndex = -1;
101
+ for (let i = body.system.length - 1; i >= 0; i--) {
102
+ if (body.system[i].cache_control) { lastCacheIndex = i; break; }
103
+ }
104
+ if (lastCacheIndex >= 0) {
105
+ for (let i = 0; i <= lastCacheIndex; i++) {
106
+ const block = body.system[i];
107
+ if (block.type === 'text' && block.text) result.system.push(block.text);
108
+ }
109
+ }
110
+ }
111
+
112
+ // messages: find last message with cache_control in content, collect 0..lastIndex
113
+ if (Array.isArray(body.messages)) {
114
+ let lastCacheIndex = -1;
115
+ for (let i = body.messages.length - 1; i >= 0; i--) {
116
+ const content = body.messages[i].content;
117
+ if (Array.isArray(content)) {
118
+ for (const block of content) {
119
+ if (block.cache_control) { lastCacheIndex = i; break; }
120
+ }
121
+ if (lastCacheIndex >= 0) break;
122
+ }
123
+ }
124
+ if (lastCacheIndex >= 0) {
125
+ for (let i = 0; i <= lastCacheIndex; i++) {
126
+ const msg = body.messages[i];
127
+ const content = msg.content;
128
+ if (typeof content === 'string') {
129
+ result.messages.push(`[${msg.role}] ${content}`);
130
+ } else if (Array.isArray(content)) {
131
+ for (const block of content) {
132
+ if (block.type === 'text' && block.text) {
133
+ result.messages.push(`[${msg.role}] ${block.text}`);
134
+ } else if (block.type === 'tool_result') {
135
+ const toolText = extractToolResultText(block);
136
+ if (toolText) result.messages.push(`[tool_result: ${block.tool_use_id}] ${toolText}`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // tools: if any tool has cache_control, all tools are cached
145
+ if (Array.isArray(body.tools)) {
146
+ const hasCache = body.tools.some(tool => tool.cache_control);
147
+ if (hasCache) {
148
+ for (const tool of body.tools) {
149
+ result.tools.push(`${tool.name}: ${tool.description || ''}`);
150
+ }
151
+ }
152
+ }
153
+
154
+ return result;
155
+ }
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, existsSync, watchFile } from 'node:fs';
2
+ import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
2
3
 
3
4
  // 跟踪所有被 watch 的日志文件
4
5
  const watchedFiles = new Map();
@@ -51,6 +52,20 @@ export function sendToClients(clients, entry) {
51
52
  });
52
53
  }
53
54
 
55
+ /**
56
+ * Send a named SSE event to all connected clients.
57
+ * @param {Array} clients - SSE client array
58
+ * @param {string} eventName - SSE event name
59
+ * @param {object} data - event payload
60
+ */
61
+ export function sendEventToClients(clients, eventName, data) {
62
+ clients.forEach(client => {
63
+ try {
64
+ client.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
65
+ } catch (err) {}
66
+ });
67
+ }
68
+
54
69
  /**
55
70
  * Watch a log file for changes and broadcast new entries.
56
71
  * @param {object} opts
@@ -88,6 +103,12 @@ export function watchLogFile(opts) {
88
103
  }
89
104
  sendToClients(clients, parsed);
90
105
  runParallelHook('onNewEntry', parsed).catch(() => {});
106
+ if (isMainAgentEntry(parsed)) {
107
+ const cached = extractCachedContent(parsed);
108
+ if (cached) {
109
+ sendEventToClients(clients, 'kv_cache_content', cached);
110
+ }
111
+ }
91
112
  } catch (err) {
92
113
  // Skip invalid entries
93
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.5.45",
3
+ "version": "1.6.0",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -42,6 +42,7 @@ import { getUserProfile } from './lib/user-profile.js';
42
42
  import { getGitDiffs } from './lib/git-diff.js';
43
43
  import { watchContextWindow, CONTEXT_WINDOW_FILE } from './lib/context-watcher.js';
44
44
  import { readLogFile, watchLogFile, startWatching, getWatchedFiles } from './lib/log-watcher.js';
45
+ import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
45
46
 
46
47
  const PREFS_FILE = join(LOG_DIR, 'preferences.json');
47
48
  const isCliMode = process.env.CCV_CLI_MODE === '1';
@@ -684,6 +685,17 @@ async function handleRequest(req, res) {
684
685
  res.write(`event: full_reload\ndata: ${JSON.stringify(entriesToSend)}\n\n`);
685
686
  }
686
687
 
688
+ // Compute KV-Cache content for latest MainAgent
689
+ for (let i = entries.length - 1; i >= 0; i--) {
690
+ if (isMainAgentEntry(entries[i])) {
691
+ const cached = extractCachedContent(entries[i]);
692
+ if (cached) {
693
+ res.write(`event: kv_cache_content\ndata: ${JSON.stringify(cached)}\n\n`);
694
+ }
695
+ break;
696
+ }
697
+ }
698
+
687
699
  req.on('close', () => {
688
700
  const idx = clients.indexOf(res);
689
701
  if (idx !== -1) clients.splice(idx, 1);