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/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-MxBRZvMh.js"></script>
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-C8GYJbe5.css">
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(/\*\*Model:\*\*\s*(.+)/);
552
- const model = modelMatch ? modelMatch[1].trim() : 'unknown';
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: **Tokens:** 19.6k / 200.0k (10%) or **Tokens:** 377.3k / 1000.0k (38%)
555
- const tokensMatch = content.match(/\*\*Tokens:\*\*\s*([\d.]+k?)\s*\/\s*([\d.]+k?)\s*\((\d+)%\)/);
556
- if (!tokensMatch) {
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
- const parseTokens = (str) => {
561
- // The k suffix is now included in the capture group
562
- if (str.endsWith('k') || str.endsWith('K')) {
563
- return parseFloat(str) * 1000;
564
- }
565
- return parseFloat(str);
566
- };
567
- const totalTokens = parseTokens(tokensMatch[1]);
568
- const contextWindow = parseTokens(tokensMatch[2]);
569
- const usedPercent = parseInt(tokensMatch[3], 10);
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.]+k?)\\s*\\|\\s*([\\d.]+)%\\s*\\|`, 'i');
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 = match[1].endsWith('k') || match[1].endsWith('K')
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 updates = {
130
- contextUsed: Math.max(0, snapshotContextUsed),
131
- };
132
- agentService.updateAgent(agentId, updates, false);
133
- log.log(`[usage_snapshot] ${agentId}: input=${inputTokens} + cacheRead=${cacheRead} + cacheCreation=${cacheCreation} = ${snapshotContextUsed} (output=${event.tokens.output || 0}) limit=${agent.contextLimit || '?'}`);
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
- if (hasModelUsageData && event.modelUsage) {
152
- const cacheRead = event.modelUsage.cacheReadInputTokens || 0;
153
- const cacheCreation = event.modelUsage.cacheCreationInputTokens || 0;
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
- if (isCodexProvider) {
163
- const turnGrowthEstimate = estimateTokensFromText(agent.lastAssignedTask) + outputTokens;
164
- const rollingEstimate = updateCodexRollingContextEstimate(agentId, turnGrowthEstimate);
165
- const plausibleSnapshotLimit = contextLimit * CODEX_PLAUSIBLE_USAGE_MULTIPLIER;
166
- const hasPlausibleSnapshot = inputTokens > 0 && inputTokens <= plausibleSnapshotLimit;
167
- contextUsed = hasPlausibleSnapshot
168
- ? Math.max(rollingEstimate, inputTokens + outputTokens)
169
- : rollingEstimate;
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 (isClaudeProvider) {
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
- contextUsed = agent.contextUsed || 0;
210
- contextLimit = agent.contextLimit || 200000;
211
- log.log(`[step_complete] Context command for ${agentId}; preserving context values from context_stats`);
212
- }
213
- else if (isClaudeProvider && hasZeroTokenUsage && hasNoModelUsage) {
214
- contextUsed = agent.contextUsed || 0;
215
- contextLimit = agent.contextLimit || 200000;
216
- log.log(`[step_complete] Claude empty usage detected for ${agentId}; preserving previous context`);
217
- }
218
- else if (isClaudeProvider && !hasModelUsageData && event.tokens) {
219
- // modelUsage was {} (empty) but we DO have event.tokens.
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(/([\w.-]+)\s*[·•]\s*([\d.]+k?)\s*\/\s*([\d.]+k?)\s*tokens\s*\((\d+)%\)/);
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 parseTokenVal = (str) => {
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 = parseInt(headerMatch[4], 10);
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*([\\.\\d]+k?)\\s*(?:tokens)?\\s*\\(([\\.\\d]+)%\\)`, 'i');
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
- const contextUsed = Math.max(0, Math.round(agent.contextUsed || 0));
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, falling back to tracked data`);
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 stats = buildStatsFromTrackedData(agent);
479
- const label = agent.provider === 'codex' ? 'estimated from turn usage' : 'tracked from token usage';
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
- if ((event.type === 'usage_snapshot' && event.tokens) || event.type === 'step_complete') {
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
- const stats = parseContextOutput(event.contextStatsRaw);
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, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "0.69.0",
3
+ "version": "0.69.2",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",