cc-viewer 1.6.0 → 1.6.2
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/{index-BawBkbaU.css → index-7ty6PCA6.css} +1 -1
- package/dist/assets/{index-D0IRVteu.js → index-CzJcswn8.js} +399 -185
- package/dist/index.html +2 -2
- package/interceptor.js +5 -9
- package/lib/context-watcher.js +63 -83
- package/lib/log-watcher.js +59 -15
- package/package.json +1 -1
- package/pty-manager.js +50 -0
- package/server.js +48 -4
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CzJcswn8.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-7ty6PCA6.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/interceptor.js
CHANGED
|
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
12
|
import { dirname, join, basename } from 'node:path';
|
|
13
13
|
import { LOG_DIR } from './findcc.js';
|
|
14
14
|
import { assembleStreamMessage, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, migrateConversationContext } from './lib/interceptor-core.js';
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -434,10 +434,8 @@ export function setupInterceptor() {
|
|
|
434
434
|
// 直接使用组装后的 message 对象作为 response.body
|
|
435
435
|
// 如果组装失败(例如非标准 SSE),则使用原始流内容
|
|
436
436
|
requestEntry.response.body = assembledMessage || streamedContent;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
updateContextWindowFromResponse(assembledMessage, requestEntry.body, requestEntry.body?.model);
|
|
440
|
-
}
|
|
437
|
+
|
|
438
|
+
|
|
441
439
|
// 移除在途请求标记,保持原始报文
|
|
442
440
|
delete requestEntry.inProgress;
|
|
443
441
|
delete requestEntry.requestId;
|
|
@@ -497,10 +495,8 @@ export function setupInterceptor() {
|
|
|
497
495
|
headers: Object.fromEntries(response.headers.entries()),
|
|
498
496
|
body: responseData
|
|
499
497
|
};
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
updateContextWindowFromResponse(responseData, requestEntry.body, requestEntry.body?.model);
|
|
503
|
-
}
|
|
498
|
+
|
|
499
|
+
|
|
504
500
|
delete requestEntry.inProgress;
|
|
505
501
|
delete requestEntry.requestId;
|
|
506
502
|
|
package/lib/context-watcher.js
CHANGED
|
@@ -1,101 +1,81 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
5
|
export const CONTEXT_WINDOW_FILE = join(homedir(), '.claude', 'context-window.json');
|
|
6
6
|
export const CLAUDE_SETTINGS_FILE = join(homedir(), '.claude', 'settings.json');
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
let
|
|
8
|
+
// Startup cache: read once, never re-read unless model changes
|
|
9
|
+
let _startupModelBase = null; // e.g. 'opus-4-6'
|
|
10
|
+
let _startupContextSize = null; // e.g. 1000000
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Read context-window.json once at startup and cache model→size mapping.
|
|
14
|
+
* Extracts model base name (e.g. 'opus-4-6') and context size from model.id (e.g. 'claude-opus-4-6[1m]').
|
|
15
|
+
* @returns {{ modelId: string|null, contextSize: number }}
|
|
14
16
|
*/
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
_lastContextSize = size;
|
|
29
|
-
clients.forEach(client => {
|
|
30
|
-
try {
|
|
31
|
-
client.write(`event: context_window\ndata: ${JSON.stringify(data.context_window)}\n\n`);
|
|
32
|
-
} catch { }
|
|
33
|
-
});
|
|
17
|
+
export function readModelContextSize() {
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(CONTEXT_WINDOW_FILE)) return { modelId: null, contextSize: 200000 };
|
|
20
|
+
const raw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
|
|
21
|
+
const data = JSON.parse(raw);
|
|
22
|
+
const modelId = data?.model?.id || null;
|
|
23
|
+
let contextSize = 200000;
|
|
24
|
+
if (modelId) {
|
|
25
|
+
const lower = modelId.toLowerCase();
|
|
26
|
+
const sizeMatch = lower.match(/\[(\d+)([km])\]/);
|
|
27
|
+
if (sizeMatch) {
|
|
28
|
+
const num = parseInt(sizeMatch[1], 10);
|
|
29
|
+
contextSize = sizeMatch[2] === 'm' ? num * 1000000 : num * 1000;
|
|
34
30
|
}
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
// Cache the base name → size mapping
|
|
32
|
+
const base = lower.replace(/^claude-/i, '').replace(/\[.*\]/, '').trim();
|
|
33
|
+
_startupModelBase = base;
|
|
34
|
+
_startupContextSize = contextSize;
|
|
35
|
+
}
|
|
36
|
+
return { modelId, contextSize };
|
|
37
|
+
} catch {
|
|
38
|
+
return { modelId: null, contextSize: 200000 };
|
|
39
|
+
}
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const num = parseInt(sizeMatch[1], 10);
|
|
54
|
-
return sizeMatch[2] === 'm' ? num * 1000000 : num * 1000;
|
|
55
|
-
}
|
|
56
|
-
for (const [key, size] of Object.entries(MODEL_CONTEXT_SIZES)) {
|
|
57
|
-
if (lower.includes(key)) return size;
|
|
42
|
+
/**
|
|
43
|
+
* Get context size for a given API model name (e.g. 'claude-opus-4-6-20250514').
|
|
44
|
+
* Uses startup cache to avoid re-reading the file.
|
|
45
|
+
* @param {string} apiModelName - model name from req.body.model
|
|
46
|
+
* @returns {number} context window size in tokens
|
|
47
|
+
*/
|
|
48
|
+
export function getContextSizeForModel(apiModelName) {
|
|
49
|
+
if (!apiModelName) return _startupContextSize || 200000;
|
|
50
|
+
const lower = apiModelName.toLowerCase();
|
|
51
|
+
// Extract base: 'claude-opus-4-6-20250514' → 'opus-4-6'
|
|
52
|
+
const base = lower.replace(/^claude-/i, '').replace(/-\d{8}$/, '').trim();
|
|
53
|
+
// Match against startup cache
|
|
54
|
+
if (_startupModelBase && base === _startupModelBase) {
|
|
55
|
+
return _startupContextSize;
|
|
58
56
|
}
|
|
59
|
-
|
|
57
|
+
// Unknown model (e.g. after /model switch to a different model) → default 200K
|
|
58
|
+
return 200000;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* Build a context_window SSE event payload from API usage data.
|
|
63
|
+
* @param {object} usage - API response usage object
|
|
64
|
+
* @param {number} contextSize - total context window size in tokens
|
|
65
|
+
* @returns {object|null} context_window event data, or null if usage missing
|
|
65
66
|
*/
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (existsSync(CONTEXT_WINDOW_FILE)) {
|
|
81
|
-
existing = JSON.parse(readFileSync(CONTEXT_WINDOW_FILE, 'utf-8'));
|
|
82
|
-
}
|
|
83
|
-
} catch { }
|
|
84
|
-
|
|
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);
|
|
90
|
-
existing.context_window = {
|
|
91
|
-
total_input_tokens: inputTokens,
|
|
92
|
-
total_output_tokens: outputTokens,
|
|
93
|
-
context_window_size: finalSize,
|
|
94
|
-
current_usage: usage,
|
|
95
|
-
used_percentage: finalPct,
|
|
96
|
-
remaining_percentage: 100 - finalPct,
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
writeFileSync(CONTEXT_WINDOW_FILE, JSON.stringify(existing) + '\n');
|
|
100
|
-
} catch { }
|
|
67
|
+
export function buildContextWindowEvent(usage, contextSize) {
|
|
68
|
+
if (!usage) return null;
|
|
69
|
+
const inputTokens = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
|
70
|
+
const outputTokens = usage.output_tokens || 0;
|
|
71
|
+
const totalTokens = inputTokens + outputTokens;
|
|
72
|
+
const usedPct = Math.round((totalTokens / contextSize) * 100);
|
|
73
|
+
return {
|
|
74
|
+
total_input_tokens: inputTokens,
|
|
75
|
+
total_output_tokens: outputTokens,
|
|
76
|
+
context_window_size: contextSize,
|
|
77
|
+
current_usage: usage,
|
|
78
|
+
used_percentage: usedPct,
|
|
79
|
+
remaining_percentage: 100 - usedPct,
|
|
80
|
+
};
|
|
101
81
|
}
|
package/lib/log-watcher.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync, existsSync, watchFile } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, watchFile, openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
2
2
|
import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
|
|
3
|
+
import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
|
|
3
4
|
|
|
4
5
|
// 跟踪所有被 watch 的日志文件
|
|
5
6
|
const watchedFiles = new Map();
|
|
@@ -79,25 +80,64 @@ export function sendEventToClients(clients, eventName, data) {
|
|
|
79
80
|
export function watchLogFile(opts) {
|
|
80
81
|
const { logFile, clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
|
|
81
82
|
if (watchedFiles.has(logFile)) return;
|
|
82
|
-
|
|
83
|
+
|
|
84
|
+
// Track byte offset instead of string length — avoids full-file re-read on every poll
|
|
85
|
+
let lastByteOffset = 0;
|
|
86
|
+
let pendingTail = ''; // incomplete entry carried across polls
|
|
83
87
|
try {
|
|
84
88
|
if (existsSync(logFile)) {
|
|
85
|
-
|
|
89
|
+
lastByteOffset = statSync(logFile).size;
|
|
86
90
|
}
|
|
87
91
|
} catch {}
|
|
92
|
+
|
|
88
93
|
watchedFiles.set(logFile, true);
|
|
89
94
|
watchFile(logFile, { interval: 500 }, () => {
|
|
90
95
|
try {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
const currentSize = statSync(logFile).size;
|
|
97
|
+
|
|
98
|
+
// File truncated (rotation or clear) — reset offset
|
|
99
|
+
if (currentSize < lastByteOffset) {
|
|
100
|
+
lastByteOffset = 0;
|
|
101
|
+
pendingTail = '';
|
|
102
|
+
}
|
|
94
103
|
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
if (currentSize <= lastByteOffset) return;
|
|
105
|
+
|
|
106
|
+
// Read only the new bytes
|
|
107
|
+
const bytesToRead = currentSize - lastByteOffset;
|
|
108
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
109
|
+
const fd = openSync(logFile, 'r');
|
|
110
|
+
try {
|
|
111
|
+
readSync(fd, buf, 0, bytesToRead, lastByteOffset);
|
|
112
|
+
} finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
lastByteOffset = currentSize;
|
|
116
|
+
|
|
117
|
+
const raw = pendingTail + buf.toString('utf-8');
|
|
118
|
+
const parts = raw.split('\n---\n');
|
|
119
|
+
|
|
120
|
+
// Last part may be incomplete — keep it for next poll
|
|
121
|
+
pendingTail = parts.pop() || '';
|
|
122
|
+
|
|
123
|
+
// If there's only the tail and no complete entries, check if tail is a complete entry
|
|
124
|
+
// (happens when the file ends without a trailing \n---\n)
|
|
125
|
+
if (parts.length === 0 && pendingTail.trim()) {
|
|
126
|
+
try {
|
|
127
|
+
JSON.parse(pendingTail);
|
|
128
|
+
// Valid JSON — treat as complete entry
|
|
129
|
+
parts.push(pendingTail);
|
|
130
|
+
pendingTail = '';
|
|
131
|
+
} catch {
|
|
132
|
+
// Incomplete — keep in pendingTail for next poll
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const validParts = parts.filter(p => p.trim());
|
|
137
|
+
if (validParts.length > 0) {
|
|
138
|
+
validParts.forEach(entry => {
|
|
98
139
|
try {
|
|
99
140
|
const parsed = JSON.parse(entry);
|
|
100
|
-
// 注入 Claude 进程 PID:CLI 模式从 PTY 获取,非 CLI 模式使用当前进程 PID
|
|
101
141
|
if (!parsed.pid) {
|
|
102
142
|
parsed.pid = getClaudePid();
|
|
103
143
|
}
|
|
@@ -108,19 +148,25 @@ export function watchLogFile(opts) {
|
|
|
108
148
|
if (cached) {
|
|
109
149
|
sendEventToClients(clients, 'kv_cache_content', cached);
|
|
110
150
|
}
|
|
151
|
+
const usage = parsed.response?.body?.usage;
|
|
152
|
+
if (usage) {
|
|
153
|
+
const contextSize = getContextSizeForModel(parsed.body?.model);
|
|
154
|
+
const cwData = buildContextWindowEvent(usage, contextSize);
|
|
155
|
+
if (cwData) {
|
|
156
|
+
sendEventToClients(clients, 'context_window', cwData);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
111
159
|
}
|
|
112
160
|
} catch (err) {
|
|
113
161
|
// Skip invalid entries
|
|
114
162
|
}
|
|
115
163
|
});
|
|
116
|
-
// 通知 stats worker 更新统计
|
|
117
164
|
notifyStatsWorker(logFile);
|
|
118
165
|
}
|
|
119
166
|
|
|
120
167
|
// 检测日志文件是否已轮转到新文件
|
|
121
168
|
const currentLogFile = getLogFile();
|
|
122
169
|
if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
|
|
123
|
-
// 轮转发生,发送 full_reload 让客户端重新加载新文件
|
|
124
170
|
const newEntries = readLogFile(currentLogFile);
|
|
125
171
|
clients.forEach(client => {
|
|
126
172
|
try {
|
|
@@ -143,13 +189,11 @@ export function watchLogFile(opts) {
|
|
|
143
189
|
* @param {Function} opts.getClaudePid
|
|
144
190
|
* @param {Function} opts.runParallelHook
|
|
145
191
|
* @param {Function} opts.notifyStatsWorker
|
|
146
|
-
* @param {Function} opts.watchContextWindow
|
|
147
192
|
* @param {Function} opts.getLogFile
|
|
148
193
|
*/
|
|
149
194
|
export function startWatching(opts) {
|
|
150
|
-
const {
|
|
195
|
+
const { clients, ...watchOpts } = opts;
|
|
151
196
|
watchLogFile({ ...watchOpts, clients });
|
|
152
|
-
watchContextWindow(clients);
|
|
153
197
|
}
|
|
154
198
|
|
|
155
199
|
/** Get the watchedFiles Map (for cleanup in stopViewer). */
|
package/package.json
CHANGED
package/pty-manager.js
CHANGED
|
@@ -184,7 +184,57 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
184
184
|
export function writeToPty(data) {
|
|
185
185
|
if (ptyProcess) {
|
|
186
186
|
ptyProcess.write(data);
|
|
187
|
+
return true;
|
|
187
188
|
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Send chunks sequentially to PTY, waiting for PTY output between each.
|
|
194
|
+
* Designed for programmatic input (multi-select, paste, etc.) where
|
|
195
|
+
* the target application (e.g. inquirer) needs time to process each chunk.
|
|
196
|
+
* @param {string[]} chunks - array of input strings to send in order
|
|
197
|
+
* @param {Function} [onComplete] - called when all chunks are sent or on error
|
|
198
|
+
* @param {object} [opts] - { timeoutMs: per-chunk timeout (default 4000), settleMs: delay after ACK (default 150) }
|
|
199
|
+
*/
|
|
200
|
+
export function writeToPtySequential(chunks, onComplete, opts = {}) {
|
|
201
|
+
const timeoutMs = opts.timeoutMs || 4000;
|
|
202
|
+
const settleMs = opts.settleMs || 150;
|
|
203
|
+
|
|
204
|
+
if (!ptyProcess || !chunks || chunks.length === 0) {
|
|
205
|
+
if (onComplete) onComplete(false);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let idx = 0;
|
|
210
|
+
let dataListener = null;
|
|
211
|
+
|
|
212
|
+
const cleanup = () => {
|
|
213
|
+
if (dataListener) {
|
|
214
|
+
dataListeners = dataListeners.filter(l => l !== dataListener);
|
|
215
|
+
dataListener = null;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const sendNext = () => {
|
|
220
|
+
if (idx >= chunks.length || !ptyProcess) {
|
|
221
|
+
cleanup();
|
|
222
|
+
if (onComplete) onComplete(true);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const chunk = chunks[idx];
|
|
227
|
+
idx++;
|
|
228
|
+
|
|
229
|
+
ptyProcess.write(chunk);
|
|
230
|
+
|
|
231
|
+
// Space, Enter, and Right arrow (→ for submit) need more time for inquirer
|
|
232
|
+
const isToggleOrSubmit = chunk === ' ' || chunk === '\r' || chunk === '\x1b[C';
|
|
233
|
+
const delay = isToggleOrSubmit ? settleMs : 80;
|
|
234
|
+
setTimeout(sendNext, delay);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
sendNext();
|
|
188
238
|
}
|
|
189
239
|
|
|
190
240
|
/**
|
package/server.js
CHANGED
|
@@ -40,7 +40,7 @@ import { checkAndUpdate } from './lib/updater.js';
|
|
|
40
40
|
import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, PLUGINS_DIR } from './lib/plugin-loader.js';
|
|
41
41
|
import { getUserProfile } from './lib/user-profile.js';
|
|
42
42
|
import { getGitDiffs } from './lib/git-diff.js';
|
|
43
|
-
import {
|
|
43
|
+
import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel } from './lib/context-watcher.js';
|
|
44
44
|
import { readLogFile, watchLogFile, startWatching, getWatchedFiles } from './lib/log-watcher.js';
|
|
45
45
|
import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
|
|
46
46
|
|
|
@@ -685,16 +685,45 @@ async function handleRequest(req, res) {
|
|
|
685
685
|
res.write(`event: full_reload\ndata: ${JSON.stringify(entriesToSend)}\n\n`);
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
-
// Compute KV-Cache content for latest MainAgent
|
|
688
|
+
// Compute KV-Cache content + context_window for latest MainAgent
|
|
689
|
+
let pushedContextWindow = false;
|
|
689
690
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
690
691
|
if (isMainAgentEntry(entries[i])) {
|
|
691
692
|
const cached = extractCachedContent(entries[i]);
|
|
692
693
|
if (cached) {
|
|
693
694
|
res.write(`event: kv_cache_content\ndata: ${JSON.stringify(cached)}\n\n`);
|
|
694
695
|
}
|
|
696
|
+
// Push initial context_window from latest MainAgent usage
|
|
697
|
+
const usage = entries[i].response?.body?.usage;
|
|
698
|
+
if (usage) {
|
|
699
|
+
const contextSize = getContextSizeForModel(entries[i].body?.model);
|
|
700
|
+
const cwData = buildContextWindowEvent(usage, contextSize);
|
|
701
|
+
if (cwData) {
|
|
702
|
+
res.write(`event: context_window\ndata: ${JSON.stringify(cwData)}\n\n`);
|
|
703
|
+
pushedContextWindow = true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
695
706
|
break;
|
|
696
707
|
}
|
|
697
708
|
}
|
|
709
|
+
// Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
|
|
710
|
+
if (!pushedContextWindow) {
|
|
711
|
+
try {
|
|
712
|
+
const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
|
|
713
|
+
const cwFile = JSON.parse(cwRaw);
|
|
714
|
+
if (cwFile?.context_window) {
|
|
715
|
+
// Recalculate with correct context size from model.id
|
|
716
|
+
const { contextSize } = readModelContextSize();
|
|
717
|
+
const cw = cwFile.context_window;
|
|
718
|
+
const inputTokens = cw.total_input_tokens || 0;
|
|
719
|
+
const outputTokens = cw.total_output_tokens || 0;
|
|
720
|
+
const totalTokens = inputTokens + outputTokens;
|
|
721
|
+
const usedPct = contextSize > 0 ? Math.round((totalTokens / contextSize) * 100) : 0;
|
|
722
|
+
const data = { ...cw, context_window_size: contextSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
|
|
723
|
+
res.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
|
|
724
|
+
}
|
|
725
|
+
} catch { }
|
|
726
|
+
}
|
|
698
727
|
|
|
699
728
|
req.on('close', () => {
|
|
700
729
|
const idx = clients.indexOf(res);
|
|
@@ -1716,7 +1745,8 @@ export async function startViewer() {
|
|
|
1716
1745
|
} catch { }
|
|
1717
1746
|
// 工作区模式下延迟到选择工作区后再启动监听
|
|
1718
1747
|
if (!isWorkspaceMode) {
|
|
1719
|
-
|
|
1748
|
+
readModelContextSize(); // Cache model→size mapping at startup
|
|
1749
|
+
startWatching(_logWatcherOpts(LOG_FILE));
|
|
1720
1750
|
startStatsWorker();
|
|
1721
1751
|
}
|
|
1722
1752
|
// CLI 模式下启动 WebSocket 服务
|
|
@@ -1746,7 +1776,7 @@ export async function startViewer() {
|
|
|
1746
1776
|
async function setupTerminalWebSocket(httpServer) {
|
|
1747
1777
|
try {
|
|
1748
1778
|
const { WebSocketServer } = await import('ws');
|
|
1749
|
-
const { writeToPty, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
|
|
1779
|
+
const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
|
|
1750
1780
|
const wss = new WebSocketServer({ noServer: true });
|
|
1751
1781
|
terminalWss = wss;
|
|
1752
1782
|
|
|
@@ -1833,6 +1863,20 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1833
1863
|
}
|
|
1834
1864
|
}
|
|
1835
1865
|
writeToPty(msg.data);
|
|
1866
|
+
} else if (msg.type === 'input-sequential') {
|
|
1867
|
+
// Programmatic sequential input: send chunks one by one, waiting for PTY ACK
|
|
1868
|
+
const state = getPtyState();
|
|
1869
|
+
if (!state.running) {
|
|
1870
|
+
try { await spawnShell(); } catch {}
|
|
1871
|
+
}
|
|
1872
|
+
const chunks = msg.chunks;
|
|
1873
|
+
if (Array.isArray(chunks) && chunks.length > 0) {
|
|
1874
|
+
writeToPtySequential(chunks, (ok) => {
|
|
1875
|
+
try {
|
|
1876
|
+
ws.send(JSON.stringify({ type: 'input-sequential-done', ok }));
|
|
1877
|
+
} catch {}
|
|
1878
|
+
}, { settleMs: msg.settleMs || 150 });
|
|
1879
|
+
}
|
|
1836
1880
|
} else if (msg.type === 'resize') {
|
|
1837
1881
|
// 存储该客户端的尺寸
|
|
1838
1882
|
clientSizes.set(ws, { cols: msg.cols, rows: msg.rows });
|