tide-commander 0.69.0 → 0.69.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/main-BOFXGJQm.css +1 -0
- package/dist/assets/{main-MxBRZvMh.js → main-DkIjeEeR.js} +58 -58
- package/dist/index.html +2 -2
- package/dist/src/packages/server/claude/backend.js +47 -19
- package/dist/src/packages/server/services/runtime-events.js +76 -58
- package/dist/src/packages/server/websocket/handlers/agent-handler.js +84 -15
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +5 -2
- package/package.json +1 -1
- package/dist/assets/main-C8GYJbe5.css +0 -1
package/dist/index.html
CHANGED
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
|
|
23
23
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
|
|
24
24
|
<title>Tide Commander</title>
|
|
25
|
-
<script type="module" crossorigin src="/assets/main-
|
|
25
|
+
<script type="module" crossorigin src="/assets/main-DkIjeEeR.js"></script>
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-uS-d4TUT.js">
|
|
28
28
|
<link rel="modulepreload" crossorigin href="/assets/vendor-three-DJ4p3FLF.js">
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/main-
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/main-BOFXGJQm.css">
|
|
30
30
|
</head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="app"></div>
|
|
@@ -547,35 +547,63 @@ export class ClaudeBackend {
|
|
|
547
547
|
*/
|
|
548
548
|
export function parseContextOutput(content) {
|
|
549
549
|
try {
|
|
550
|
+
const parseTokenValue = (raw) => {
|
|
551
|
+
const normalized = raw.trim().replace(/,/g, '');
|
|
552
|
+
const suffix = normalized.slice(-1).toLowerCase();
|
|
553
|
+
const numericPart = suffix === 'k' || suffix === 'm'
|
|
554
|
+
? normalized.slice(0, -1)
|
|
555
|
+
: normalized;
|
|
556
|
+
const value = parseFloat(numericPart);
|
|
557
|
+
if (!Number.isFinite(value))
|
|
558
|
+
return NaN;
|
|
559
|
+
if (suffix === 'k')
|
|
560
|
+
return value * 1000;
|
|
561
|
+
if (suffix === 'm')
|
|
562
|
+
return value * 1000000;
|
|
563
|
+
return value;
|
|
564
|
+
};
|
|
550
565
|
// Extract model name
|
|
551
|
-
const modelMatch = content.match(
|
|
552
|
-
|
|
566
|
+
const modelMatch = content.match(/(?:\*\*)?Model:(?:\*\*)?\s*(.+)/i);
|
|
567
|
+
let model = modelMatch ? modelMatch[1].trim() : 'unknown';
|
|
553
568
|
// Extract total tokens and context window
|
|
554
|
-
// Format:
|
|
555
|
-
|
|
556
|
-
|
|
569
|
+
// Format examples:
|
|
570
|
+
// **Tokens:** 19.6k / 200.0k (10%)
|
|
571
|
+
// **Tokens:** 46,123 / 200,000 (23.4%)
|
|
572
|
+
// claude-opus-4-6 · 46k/200k tokens (23%)
|
|
573
|
+
const tokensMatch = content.match(/(?:\*\*)?Tokens:(?:\*\*)?\s*([\d.,]+(?:[kKmM])?)\s*\/\s*([\d.,]+(?:[kKmM])?)\s*\(([\d.]+)%\)/i);
|
|
574
|
+
const visualMatch = content.match(/([^\n]+?)\s*[·•]\s*([\d.,]+(?:[kKmM])?)\s*\/\s*([\d.,]+(?:[kKmM])?)\s*tokens?\s*\(([\d.]+)%\)/i);
|
|
575
|
+
if (!tokensMatch && !visualMatch) {
|
|
557
576
|
log.log('parseContextOutput: Could not parse tokens line');
|
|
558
577
|
return null;
|
|
559
578
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
579
|
+
let totalTokenRaw = '';
|
|
580
|
+
let contextWindowRaw = '';
|
|
581
|
+
let usedPercentRaw = '';
|
|
582
|
+
if (visualMatch) {
|
|
583
|
+
model = visualMatch[1].trim();
|
|
584
|
+
totalTokenRaw = visualMatch[2];
|
|
585
|
+
contextWindowRaw = visualMatch[3];
|
|
586
|
+
usedPercentRaw = visualMatch[4];
|
|
587
|
+
}
|
|
588
|
+
else if (tokensMatch) {
|
|
589
|
+
totalTokenRaw = tokensMatch[1];
|
|
590
|
+
contextWindowRaw = tokensMatch[2];
|
|
591
|
+
usedPercentRaw = tokensMatch[3];
|
|
592
|
+
}
|
|
593
|
+
const totalTokens = parseTokenValue(totalTokenRaw);
|
|
594
|
+
const contextWindow = parseTokenValue(contextWindowRaw);
|
|
595
|
+
const usedPercent = parseFloat(usedPercentRaw);
|
|
596
|
+
if (!Number.isFinite(totalTokens) || !Number.isFinite(contextWindow) || !Number.isFinite(usedPercent)) {
|
|
597
|
+
log.log('parseContextOutput: Parsed non-finite token values');
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
570
600
|
// Parse category table
|
|
571
601
|
const parseCategory = (name) => {
|
|
572
602
|
// Match: | Category Name | 3.1k | 1.6% |
|
|
573
|
-
const regex = new RegExp(`\\|\\s*${name}\\s*\\|\\s*([\\d
|
|
603
|
+
const regex = new RegExp(`\\|\\s*${name}\\s*\\|\\s*([\\d.,]+(?:[kKmM])?)\\s*\\|\\s*([\\d.]+)%\\s*\\|`, 'i');
|
|
574
604
|
const match = content.match(regex);
|
|
575
605
|
if (match) {
|
|
576
|
-
const tokens =
|
|
577
|
-
? parseFloat(match[1]) * 1000
|
|
578
|
-
: parseFloat(match[1]);
|
|
606
|
+
const tokens = parseTokenValue(match[1]);
|
|
579
607
|
return { tokens, percent: parseFloat(match[2]) };
|
|
580
608
|
}
|
|
581
609
|
return { tokens: 0, percent: 0 };
|
|
@@ -113,6 +113,11 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
113
113
|
agentService.updateAgent(agentId, { currentTool: undefined });
|
|
114
114
|
break;
|
|
115
115
|
case 'usage_snapshot': {
|
|
116
|
+
// Skip subagent events — their token counts reflect the subagent's context,
|
|
117
|
+
// not the parent's. Updating the parent's contextUsed here would corrupt it.
|
|
118
|
+
if (event.parentToolUseId) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
116
121
|
// Real-time context tracking from streaming usage data.
|
|
117
122
|
// Context window usage = input tokens only (output tokens don't count toward the limit).
|
|
118
123
|
// total_input = cache_read + cache_creation + input_tokens (the full prompt size).
|
|
@@ -126,17 +131,44 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
126
131
|
// Output tokens are the model's response and don't count toward the limit.
|
|
127
132
|
const snapshotContextUsed = cacheRead + cacheCreation + inputTokens;
|
|
128
133
|
if (snapshotContextUsed > 0) {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
const effectiveLimit = agent.contextLimit || 200000;
|
|
135
|
+
// Guard against cumulative session totals: if the sum exceeds the
|
|
136
|
+
// context window, it can't represent per-request context fill.
|
|
137
|
+
if (snapshotContextUsed > effectiveLimit) {
|
|
138
|
+
log.log(`[usage_snapshot] ${agentId}: sum ${snapshotContextUsed} exceeds limit ${effectiveLimit} (likely cumulative); skipping`);
|
|
139
|
+
// If the agent's existing contextUsed is also stale (exceeds limit),
|
|
140
|
+
// reset it to 0 so the UI doesn't keep showing an impossible value.
|
|
141
|
+
if ((agent.contextUsed || 0) > effectiveLimit) {
|
|
142
|
+
agentService.updateAgent(agentId, { contextUsed: 0 }, false);
|
|
143
|
+
log.log(`[usage_snapshot] ${agentId}: reset stale contextUsed ${agent.contextUsed} to 0`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const updates = {
|
|
148
|
+
contextUsed: Math.max(0, snapshotContextUsed),
|
|
149
|
+
};
|
|
150
|
+
agentService.updateAgent(agentId, updates, false);
|
|
151
|
+
log.log(`[usage_snapshot] ${agentId}: input=${inputTokens} + cacheRead=${cacheRead} + cacheCreation=${cacheCreation} = ${snapshotContextUsed} (output=${event.tokens.output || 0}) limit=${effectiveLimit}`);
|
|
152
|
+
}
|
|
134
153
|
}
|
|
135
154
|
}
|
|
136
155
|
}
|
|
137
156
|
break;
|
|
138
157
|
}
|
|
139
158
|
case 'step_complete': {
|
|
159
|
+
// For subagent events, only accumulate cost (tokensUsed) but don't touch
|
|
160
|
+
// contextUsed/contextLimit/status — those belong to the subagent's context
|
|
161
|
+
// window, not the parent's.
|
|
162
|
+
if (event.parentToolUseId) {
|
|
163
|
+
const subTokensUsed = (event.tokens?.input || 0) + (event.tokens?.output || 0);
|
|
164
|
+
if (subTokensUsed > 0) {
|
|
165
|
+
agentService.updateAgent(agentId, {
|
|
166
|
+
tokensUsed: (agent.tokensUsed || 0) + subTokensUsed,
|
|
167
|
+
}, false);
|
|
168
|
+
}
|
|
169
|
+
log.log(`[step_complete] Subagent event for ${agentId} (parentToolUseId=${event.parentToolUseId}); skipping context update, added ${subTokensUsed} to tokensUsed`);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
140
172
|
markStepCompleteReceived(agentId);
|
|
141
173
|
const isClaudeProvider = (agent.provider ?? 'claude') === 'claude';
|
|
142
174
|
const isCodexProvider = (agent.provider ?? 'claude') === 'codex';
|
|
@@ -148,41 +180,40 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
148
180
|
// We must check that it has actual data before using it, otherwise we'd
|
|
149
181
|
// zero out contextUsed (since all fields would be undefined → 0).
|
|
150
182
|
const hasModelUsageData = event.modelUsage && Object.keys(event.modelUsage).length > 0;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
// For CLAUDE agents: the usage_snapshot handler (from the streaming assistant
|
|
184
|
+
// event) already set the authoritative per-turn contextUsed value. The
|
|
185
|
+
// step_complete's modelUsage/tokens may contain CUMULATIVE session-wide totals
|
|
186
|
+
// which would inflate the tracked context. Only extract contextLimit (window
|
|
187
|
+
// size) from modelUsage — never override contextUsed for Claude agents.
|
|
188
|
+
//
|
|
189
|
+
// For CODEX agents: there's no usage_snapshot, so step_complete is the only
|
|
190
|
+
// source of context estimation.
|
|
191
|
+
if (isClaudeProvider) {
|
|
192
|
+
// Extract contextLimit from modelUsage if available
|
|
193
|
+
if (hasModelUsageData && event.modelUsage?.contextWindow) {
|
|
194
|
+
contextLimit = event.modelUsage.contextWindow;
|
|
195
|
+
}
|
|
196
|
+
// Preserve contextUsed from usage_snapshot (set during streaming)
|
|
197
|
+
contextUsed = agent.contextUsed || 0;
|
|
198
|
+
log.log(`[step_complete] Claude agent ${agentId}: preserving usage_snapshot contextUsed=${contextUsed}, contextLimit=${contextLimit}`);
|
|
199
|
+
}
|
|
200
|
+
else if (hasModelUsageData && event.modelUsage) {
|
|
154
201
|
const inputTokens = event.modelUsage.inputTokens || 0;
|
|
155
202
|
const outputTokens = event.modelUsage.outputTokens || 0;
|
|
156
|
-
// Only update contextLimit if the event actually carries contextWindow.
|
|
157
|
-
// If undefined, keep the existing agent.contextLimit (which may have been
|
|
158
|
-
// bumped by usage_snapshot or a previous step_complete).
|
|
159
203
|
if (event.modelUsage.contextWindow) {
|
|
160
204
|
contextLimit = event.modelUsage.contextWindow;
|
|
161
205
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
// Context window = input side only (output tokens don't count toward the limit)
|
|
173
|
-
contextUsed = cacheRead + cacheCreation + inputTokens;
|
|
174
|
-
}
|
|
175
|
-
log.log(`[step_complete] modelUsage data for ${agentId}: cacheRead=${cacheRead}, cacheCreation=${cacheCreation}, input=${inputTokens}, contextWindow=${event.modelUsage.contextWindow || 'none'}`);
|
|
206
|
+
const turnGrowthEstimate = estimateTokensFromText(agent.lastAssignedTask) + outputTokens;
|
|
207
|
+
const rollingEstimate = updateCodexRollingContextEstimate(agentId, turnGrowthEstimate);
|
|
208
|
+
const plausibleSnapshotLimit = contextLimit * CODEX_PLAUSIBLE_USAGE_MULTIPLIER;
|
|
209
|
+
const hasPlausibleSnapshot = inputTokens > 0 && inputTokens <= plausibleSnapshotLimit;
|
|
210
|
+
contextUsed = hasPlausibleSnapshot
|
|
211
|
+
? Math.max(rollingEstimate, inputTokens + outputTokens)
|
|
212
|
+
: rollingEstimate;
|
|
213
|
+
log.log(`[step_complete] Codex modelUsage for ${agentId}: input=${inputTokens}, contextWindow=${event.modelUsage.contextWindow || 'none'}`);
|
|
176
214
|
}
|
|
177
215
|
else if (event.tokens) {
|
|
178
|
-
if (
|
|
179
|
-
const cacheRead = event.tokens.cacheRead || 0;
|
|
180
|
-
const cacheCreation = event.tokens.cacheCreation || 0;
|
|
181
|
-
const inputTokens = event.tokens.input || 0;
|
|
182
|
-
// Context window = input side only
|
|
183
|
-
contextUsed = cacheRead + cacheCreation + inputTokens;
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
216
|
+
if (isCodexProvider) {
|
|
186
217
|
const inputTokens = event.tokens.input || 0;
|
|
187
218
|
const outputTokens = event.tokens.output || 0;
|
|
188
219
|
const turnGrowthEstimate = estimateTokensFromText(agent.lastAssignedTask) + outputTokens;
|
|
@@ -194,36 +225,23 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
194
225
|
: rollingEstimate;
|
|
195
226
|
contextLimit = agent.contextLimit || DEFAULT_CODEX_CONTEXT_WINDOW;
|
|
196
227
|
}
|
|
228
|
+
// For Claude with tokens but no modelUsage — usage_snapshot already handled it
|
|
197
229
|
}
|
|
198
|
-
const hasZeroTokenUsage = !!event.tokens
|
|
199
|
-
&& (event.tokens.input || 0) === 0
|
|
200
|
-
&& (event.tokens.output || 0) === 0
|
|
201
|
-
&& (event.tokens.cacheRead || 0) === 0
|
|
202
|
-
&& (event.tokens.cacheCreation || 0) === 0;
|
|
203
|
-
// Treat empty objects {} as "no model usage" (they have no meaningful data)
|
|
204
|
-
const hasNoModelUsage = !hasModelUsageData;
|
|
205
230
|
// For /context, /cost, /compact: the context_stats event already set the
|
|
206
231
|
// authoritative contextUsed/contextLimit values. Don't overwrite them with
|
|
207
232
|
// zero-token step_complete data from the local command.
|
|
208
233
|
if (isContextCommand) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// The usage_snapshot handler already set the correct contextUsed value
|
|
221
|
-
// from the assistant event's usage data. Don't overwrite it.
|
|
222
|
-
// The event.tokens here come from the result event, which for Claude
|
|
223
|
-
// in --print mode may have different semantics. Prefer the last
|
|
224
|
-
// usage_snapshot value which is the most accurate real-time reading.
|
|
225
|
-
log.log(`[step_complete] Claude empty modelUsage but has tokens for ${agentId}; preserving usage_snapshot contextUsed=${agent.contextUsed}`);
|
|
226
|
-
contextUsed = agent.contextUsed || 0;
|
|
234
|
+
const stats = agent.contextStats;
|
|
235
|
+
if (stats && stats.contextWindow > 0) {
|
|
236
|
+
contextUsed = Math.max(0, stats.totalTokens || 0);
|
|
237
|
+
contextLimit = Math.max(1, stats.contextWindow || 200000);
|
|
238
|
+
log.log(`[step_complete] Context command for ${agentId}; preserving context from context_stats ${contextUsed}/${contextLimit}`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
contextUsed = agent.contextUsed || 0;
|
|
242
|
+
contextLimit = agent.contextLimit || 200000;
|
|
243
|
+
log.log(`[step_complete] Context command for ${agentId}; preserving context values from tracked fields`);
|
|
244
|
+
}
|
|
227
245
|
}
|
|
228
246
|
// Don't clamp contextUsed to contextLimit - models can have up to 1M context.
|
|
229
247
|
// The contextLimit comes from modelUsage.contextWindow which is authoritative.
|
|
@@ -192,24 +192,36 @@ export async function handleCollapseContext(ctx, payload) {
|
|
|
192
192
|
*/
|
|
193
193
|
function parseVisualContextOutput(content) {
|
|
194
194
|
try {
|
|
195
|
+
const parseTokenVal = (raw) => {
|
|
196
|
+
const normalized = raw.trim().replace(/,/g, '');
|
|
197
|
+
const suffix = normalized.slice(-1).toLowerCase();
|
|
198
|
+
const numericPart = suffix === 'k' || suffix === 'm'
|
|
199
|
+
? normalized.slice(0, -1)
|
|
200
|
+
: normalized;
|
|
201
|
+
const value = parseFloat(numericPart);
|
|
202
|
+
if (!Number.isFinite(value))
|
|
203
|
+
return NaN;
|
|
204
|
+
if (suffix === 'k')
|
|
205
|
+
return value * 1000;
|
|
206
|
+
if (suffix === 'm')
|
|
207
|
+
return value * 1000000;
|
|
208
|
+
return value;
|
|
209
|
+
};
|
|
195
210
|
// Match: model-name · 46k/200k tokens (23%)
|
|
196
|
-
const headerMatch = content.match(/([
|
|
211
|
+
const headerMatch = content.match(/([^\n]+?)\s*[·•]\s*([\d.,]+(?:[kKmM])?)\s*\/\s*([\d.,]+(?:[kKmM])?)\s*tokens?\s*\(([\d.]+)%\)/i);
|
|
197
212
|
if (!headerMatch) {
|
|
198
213
|
return null;
|
|
199
214
|
}
|
|
200
|
-
const
|
|
201
|
-
if (str.endsWith('k') || str.endsWith('K')) {
|
|
202
|
-
return parseFloat(str) * 1000;
|
|
203
|
-
}
|
|
204
|
-
return parseFloat(str);
|
|
205
|
-
};
|
|
206
|
-
const model = headerMatch[1];
|
|
215
|
+
const model = headerMatch[1].trim();
|
|
207
216
|
const totalTokens = parseTokenVal(headerMatch[2]);
|
|
208
217
|
const contextWindow = parseTokenVal(headerMatch[3]);
|
|
209
|
-
const usedPercent =
|
|
218
|
+
const usedPercent = parseFloat(headerMatch[4]);
|
|
219
|
+
if (!Number.isFinite(totalTokens) || !Number.isFinite(contextWindow) || !Number.isFinite(usedPercent)) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
210
222
|
// Parse categories from visual format: "⛁ Category Name: 6.7k tokens (3.4%)" or "⛁ Category Name: 479 tokens (0.2%)"
|
|
211
223
|
const parseVisualCategory = (name) => {
|
|
212
|
-
const regex = new RegExp(`${name}:\\s*([
|
|
224
|
+
const regex = new RegExp(`${name}:\\s*([\\d.,]+(?:[kKmM])?)\\s*(?:tokens)?\\s*\\(([\\d.]+)%\\)`, 'i');
|
|
213
225
|
const match = content.match(regex);
|
|
214
226
|
if (match) {
|
|
215
227
|
return { tokens: parseTokenVal(match[1]), percent: parseFloat(match[2]) };
|
|
@@ -263,6 +275,24 @@ function fetchContextFromCLI(sessionId, cwd) {
|
|
|
263
275
|
});
|
|
264
276
|
});
|
|
265
277
|
}
|
|
278
|
+
function waitForContextStatsUpdate(agentId, previousLastUpdated, timeoutMs = 2500) {
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
const startedAt = Date.now();
|
|
281
|
+
const interval = setInterval(() => {
|
|
282
|
+
const agent = agentService.getAgent(agentId);
|
|
283
|
+
const lastUpdated = agent?.contextStats?.lastUpdated || 0;
|
|
284
|
+
if (lastUpdated > previousLastUpdated) {
|
|
285
|
+
clearInterval(interval);
|
|
286
|
+
resolve(true);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
290
|
+
clearInterval(interval);
|
|
291
|
+
resolve(false);
|
|
292
|
+
}
|
|
293
|
+
}, 100);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
266
296
|
function tryStreamJson(sessionId, cwd) {
|
|
267
297
|
return new Promise((resolve) => {
|
|
268
298
|
const executable = claudeBackend.getExecutablePath();
|
|
@@ -381,7 +411,7 @@ function tryPlainPipe(sessionId, cwd) {
|
|
|
381
411
|
});
|
|
382
412
|
}
|
|
383
413
|
/** Try every known format parser on the combined output. */
|
|
384
|
-
function parseAllFormats(raw) {
|
|
414
|
+
export function parseAllFormats(raw) {
|
|
385
415
|
// Strip ANSI escape codes
|
|
386
416
|
const stripped = raw.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
387
417
|
// 1. Markdown table format (from <local-command-stdout> or raw)
|
|
@@ -404,8 +434,22 @@ function parseAllFormats(raw) {
|
|
|
404
434
|
* Build context stats from tracked agent data (fallback when CLI fetch isn't possible).
|
|
405
435
|
*/
|
|
406
436
|
function buildStatsFromTrackedData(agent) {
|
|
407
|
-
|
|
437
|
+
// Always use the real-time tracked values from step_complete/usage_snapshot events.
|
|
438
|
+
// These are updated on every API turn and are the most accurate source of truth.
|
|
439
|
+
// Do NOT prefer agent.contextStats here — those can become stale when previous
|
|
440
|
+
// fallback calls save their (potentially wrong) output back to agent.contextStats
|
|
441
|
+
// via broadcastContextStats, creating a self-reinforcing feedback loop.
|
|
408
442
|
const contextLimit = Math.max(1, Math.round(agent.contextLimit || 200000));
|
|
443
|
+
const rawContextUsed = Math.max(0, Math.round(agent.contextUsed || 0));
|
|
444
|
+
// Guard: contextUsed can never exceed contextLimit. Values above the limit are
|
|
445
|
+
// cumulative session totals from result events, not per-request context fill.
|
|
446
|
+
const contextUsed = rawContextUsed <= contextLimit ? rawContextUsed : 0;
|
|
447
|
+
if (rawContextUsed > contextLimit) {
|
|
448
|
+
log.log(`[contextStats] Building from tracked data: raw ${rawContextUsed} exceeds limit ${contextLimit}, reset to 0`);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
log.log(`[contextStats] Building from tracked data: ${contextUsed}/${contextLimit}`);
|
|
452
|
+
}
|
|
409
453
|
const usedPercent = Math.min(100, Math.round((contextUsed / contextLimit) * 100));
|
|
410
454
|
const freeTokens = Math.max(0, contextLimit - contextUsed);
|
|
411
455
|
const model = agent.model || agent.codexModel || 'unknown';
|
|
@@ -428,6 +472,14 @@ function buildStatsFromTrackedData(agent) {
|
|
|
428
472
|
* Broadcast context stats to UI (both the modal and the context bar).
|
|
429
473
|
*/
|
|
430
474
|
function broadcastContextStats(ctx, agentId, stats, label) {
|
|
475
|
+
// Sanitize: if totalTokens exceeds contextWindow, it's a cumulative artifact — reset to 0.
|
|
476
|
+
if (stats.totalTokens > stats.contextWindow) {
|
|
477
|
+
stats = { ...stats, totalTokens: 0, usedPercent: 0, categories: {
|
|
478
|
+
...stats.categories,
|
|
479
|
+
messages: { tokens: 0, percent: 0 },
|
|
480
|
+
freeSpace: { tokens: stats.contextWindow, percent: 100 },
|
|
481
|
+
} };
|
|
482
|
+
}
|
|
431
483
|
const freePercent = stats.categories?.freeSpace?.percent ?? (100 - stats.usedPercent);
|
|
432
484
|
agentService.updateAgent(agentId, {
|
|
433
485
|
contextStats: stats,
|
|
@@ -468,15 +520,32 @@ export async function handleRequestContextStats(ctx, payload) {
|
|
|
468
520
|
broadcastContextStats(ctx, payload.agentId, stats, 'from CLI');
|
|
469
521
|
return;
|
|
470
522
|
}
|
|
471
|
-
log.warn(`[contextStats] CLI fetch returned null,
|
|
523
|
+
log.warn(`[contextStats] CLI fetch returned null, trying in-session /context command`);
|
|
472
524
|
}
|
|
473
525
|
catch (err) {
|
|
474
526
|
log.error(`[contextStats] CLI fetch failed: ${err}`);
|
|
475
527
|
}
|
|
528
|
+
// Fallback #2 for Claude: ask the runtime session directly and let runtime-listeners
|
|
529
|
+
// parse/broadcast the authoritative context_stats event.
|
|
530
|
+
try {
|
|
531
|
+
const beforeUpdate = agent.contextStats?.lastUpdated || 0;
|
|
532
|
+
await runtimeService.sendSilentCommand(payload.agentId, '/context');
|
|
533
|
+
ctx.sendActivity(payload.agentId, 'Fetching context from Claude session');
|
|
534
|
+
const gotContextStats = await waitForContextStatsUpdate(payload.agentId, beforeUpdate);
|
|
535
|
+
if (gotContextStats) {
|
|
536
|
+
log.log(`[contextStats] In-session /context produced context_stats for ${agent.name}`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
log.warn(`[contextStats] In-session /context produced no context_stats, falling back to tracked data`);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
log.error(`[contextStats] In-session /context failed: ${err}`);
|
|
543
|
+
}
|
|
476
544
|
}
|
|
477
545
|
// Fallback: generate from tracked data
|
|
478
|
-
const
|
|
479
|
-
const
|
|
546
|
+
const latestAgent = agentService.getAgent(payload.agentId) || agent;
|
|
547
|
+
const stats = buildStatsFromTrackedData(latestAgent);
|
|
548
|
+
const label = latestAgent.provider === 'codex' ? 'estimated from turn usage' : 'tracked from token usage';
|
|
480
549
|
broadcastContextStats(ctx, payload.agentId, stats, label);
|
|
481
550
|
}
|
|
482
551
|
/**
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'child_process';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { parseContextOutput } from '../../claude/backend.js';
|
|
5
|
+
import { parseAllFormats } from '../handlers/agent-handler.js';
|
|
5
6
|
import { agentService, runtimeService } from '../../services/index.js';
|
|
6
7
|
import { logger, formatToolActivity } from '../../utils/index.js';
|
|
7
8
|
import { parseBossDelegation, parseBossSpawn, getBossForSubordinate, clearDelegation } from '../handlers/boss-response-handler.js';
|
|
@@ -143,7 +144,8 @@ export function setupRuntimeListeners(ctx) {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
// Real-time context tracking: broadcast lightweight context_update on usage_snapshot and step_complete
|
|
146
|
-
|
|
147
|
+
// Skip subagent events — their token counts reflect the subagent's own context, not the parent's.
|
|
148
|
+
if (!event.parentToolUseId && ((event.type === 'usage_snapshot' && event.tokens) || event.type === 'step_complete')) {
|
|
147
149
|
const agent = agentService.getAgent(agentId);
|
|
148
150
|
if (agent) {
|
|
149
151
|
ctx.broadcast({
|
|
@@ -158,7 +160,8 @@ export function setupRuntimeListeners(ctx) {
|
|
|
158
160
|
}
|
|
159
161
|
if (event.type === 'context_stats' && event.contextStatsRaw) {
|
|
160
162
|
log.log(`[context_stats] Received for agent ${agentId}, raw length: ${event.contextStatsRaw.length}`);
|
|
161
|
-
|
|
163
|
+
// Try all known formats: markdown table, visual bar chart, etc.
|
|
164
|
+
const stats = parseAllFormats(event.contextStatsRaw) || parseContextOutput(event.contextStatsRaw);
|
|
162
165
|
if (stats) {
|
|
163
166
|
log.log(`[context_stats] Parsed: ${stats.usedPercent}% used, ${stats.totalTokens}/${stats.contextWindow} tokens`);
|
|
164
167
|
agentService.updateAgent(agentId, {
|