@link-assistant/hive-mind 1.52.0 → 1.53.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.53.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 906f61e: Add Playwright MCP browser automation fallback hints to all tools (opencode, agent), WebSearch fallback guidance to all tools (claude, codex, opencode, agent), and --no-playwright-mcp flag to physically disable Playwright MCP server connection per session without affecting global registration.
8
+
9
+ ## 1.52.1
10
+
11
+ ### Patch Changes
12
+
13
+ - d5d3762: Fix calculation bugs and format unification for budget stats using decimal.js-light for precision.
14
+
3
15
  ## 1.52.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -70,6 +70,7 @@
70
70
  "@sentry/node": "^10.15.0",
71
71
  "@sentry/profiling-node": "^10.15.0",
72
72
  "dayjs": "^1.11.19",
73
+ "decimal.js-light": "^2.5.1",
73
74
  "secretlint": "^11.2.5",
74
75
  "semver": "^7.7.3"
75
76
  },
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Decimal from 'decimal.js-light';
4
+ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
5
+
6
+ export const createTokenFieldAvailability = () => ({
7
+ inputTokens: false,
8
+ outputTokens: false,
9
+ reasoningTokens: false,
10
+ cacheReadTokens: false,
11
+ cacheWriteTokens: false,
12
+ });
13
+
14
+ export const createAgentTokenUsage = () => ({
15
+ inputTokens: 0,
16
+ outputTokens: 0,
17
+ reasoningTokens: 0,
18
+ cacheReadTokens: 0,
19
+ cacheWriteTokens: 0,
20
+ totalCost: 0,
21
+ stepCount: 0,
22
+ requestedModelId: null,
23
+ respondedModelId: null,
24
+ contextLimit: null,
25
+ outputLimit: null,
26
+ peakContextUsage: 0,
27
+ tokenFieldAvailability: createTokenFieldAvailability(),
28
+ });
29
+
30
+ const addObservedTokenValue = (usage, source, sourceFieldName, targetFieldName) => {
31
+ if (!source || !Object.hasOwn(source, sourceFieldName)) return;
32
+ usage.tokenFieldAvailability ||= createTokenFieldAvailability();
33
+ usage.tokenFieldAvailability[targetFieldName] = true;
34
+ const value = source[sourceFieldName];
35
+ if (Number.isFinite(value)) usage[targetFieldName] = (usage[targetFieldName] || 0) + value;
36
+ };
37
+
38
+ const getTokenCount = value => (Number.isFinite(value) ? value : 0);
39
+
40
+ export const accumulateAgentStepFinishUsage = (usage, data) => {
41
+ if (!usage || data?.type !== 'step_finish' || !data.part?.tokens) return false;
42
+
43
+ const tokens = data.part.tokens;
44
+ usage.stepCount = (usage.stepCount || 0) + 1;
45
+ usage.tokenFieldAvailability ||= createTokenFieldAvailability();
46
+
47
+ addObservedTokenValue(usage, tokens, 'input', 'inputTokens');
48
+ addObservedTokenValue(usage, tokens, 'output', 'outputTokens');
49
+ addObservedTokenValue(usage, tokens, 'reasoning', 'reasoningTokens');
50
+ if (tokens.cache) {
51
+ addObservedTokenValue(usage, tokens.cache, 'read', 'cacheReadTokens');
52
+ addObservedTokenValue(usage, tokens.cache, 'write', 'cacheWriteTokens');
53
+ }
54
+
55
+ if (Number.isFinite(data.part.cost)) {
56
+ usage.totalCost = new Decimal(usage.totalCost || 0).plus(data.part.cost).toNumber();
57
+ }
58
+
59
+ if (data.part.model) {
60
+ if (data.part.model.requestedModelID) usage.requestedModelId = data.part.model.requestedModelID;
61
+ if (data.part.model.respondedModelID) usage.respondedModelId = data.part.model.respondedModelID;
62
+ }
63
+
64
+ if (data.part.context) {
65
+ if (data.part.context.contextLimit) usage.contextLimit = data.part.context.contextLimit;
66
+ if (data.part.context.outputLimit) usage.outputLimit = data.part.context.outputLimit;
67
+ const stepContextUsage = getTokenCount(tokens.input) + getTokenCount(tokens.cache?.read);
68
+ if (stepContextUsage > (usage.peakContextUsage || 0)) {
69
+ usage.peakContextUsage = stepContextUsage;
70
+ }
71
+ }
72
+
73
+ return true;
74
+ };
75
+
76
+ /**
77
+ * Parse Agent/OpenCode NDJSON output to extract token usage from step_finish events.
78
+ * @param {string} output - Raw JSONL output from the command
79
+ * @returns {Object} Aggregated token usage and cost data
80
+ */
81
+ export const parseAgentTokenUsage = output => {
82
+ const usage = createAgentTokenUsage();
83
+
84
+ for (const rawLine of output.split('\n')) {
85
+ const line = rawLine.trim();
86
+ if (!line || !line.startsWith('{')) continue;
87
+
88
+ try {
89
+ accumulateAgentStepFinishUsage(usage, sanitizeObjectStrings(JSON.parse(line)));
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ return usage;
96
+ };
package/src/agent.lib.mjs CHANGED
@@ -18,93 +18,18 @@ import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
20
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
21
+ import Decimal from 'decimal.js-light';
21
22
  import { agentModels, defaultModels, freeToBaseModelMap } from './models/index.mjs';
23
+ import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
24
+ import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
25
+
26
+ export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
22
27
 
23
28
  // Import pricing functions from claude.lib.mjs
24
29
  // We reuse fetchModelInfo and checkModelVisionCapability to get data from models.dev API
25
30
  const claudeLib = await import('./claude.lib.mjs');
26
31
  const { fetchModelInfo, checkModelVisionCapability } = claudeLib;
27
32
 
28
- /**
29
- * Parse agent JSON output to extract token usage from step_finish events
30
- * Agent outputs NDJSON (newline-delimited JSON) with step_finish events containing token data
31
- * @param {string} output - Raw stdout output from agent command
32
- * @returns {Object} Aggregated token usage and cost data
33
- */
34
- export const parseAgentTokenUsage = output => {
35
- const usage = {
36
- inputTokens: 0,
37
- outputTokens: 0,
38
- reasoningTokens: 0,
39
- cacheReadTokens: 0,
40
- cacheWriteTokens: 0,
41
- totalCost: 0,
42
- stepCount: 0,
43
- // Issue #1526: Track model and context info from step_finish events
44
- requestedModelId: null,
45
- respondedModelId: null,
46
- contextLimit: null,
47
- outputLimit: null,
48
- peakContextUsage: 0, // Track peak context usage across steps
49
- };
50
-
51
- // Try to parse each line as JSON (agent outputs NDJSON format)
52
- const lines = output.split('\n');
53
- for (const line of lines) {
54
- const trimmedLine = line.trim();
55
- if (!trimmedLine || !trimmedLine.startsWith('{')) continue;
56
-
57
- try {
58
- const parsed = sanitizeObjectStrings(JSON.parse(trimmedLine));
59
-
60
- // Look for step_finish events which contain token usage
61
- if (parsed.type === 'step_finish' && parsed.part?.tokens) {
62
- const tokens = parsed.part.tokens;
63
- usage.stepCount++;
64
-
65
- // Add token counts
66
- if (tokens.input) usage.inputTokens += tokens.input;
67
- if (tokens.output) usage.outputTokens += tokens.output;
68
- if (tokens.reasoning) usage.reasoningTokens += tokens.reasoning;
69
-
70
- // Handle cache tokens (can be in different formats)
71
- if (tokens.cache) {
72
- if (tokens.cache.read) usage.cacheReadTokens += tokens.cache.read;
73
- if (tokens.cache.write) usage.cacheWriteTokens += tokens.cache.write;
74
- }
75
-
76
- // Add cost from step_finish (usually 0 for free models like grok-code)
77
- if (parsed.part.cost !== undefined) {
78
- usage.totalCost += parsed.part.cost;
79
- }
80
-
81
- // Issue #1526: Extract model info from step_finish events
82
- if (parsed.part.model) {
83
- if (parsed.part.model.requestedModelID) usage.requestedModelId = parsed.part.model.requestedModelID;
84
- if (parsed.part.model.respondedModelID) usage.respondedModelId = parsed.part.model.respondedModelID;
85
- }
86
-
87
- // Issue #1526: Extract context limits and track peak context usage
88
- if (parsed.part.context) {
89
- if (parsed.part.context.contextLimit) usage.contextLimit = parsed.part.context.contextLimit;
90
- if (parsed.part.context.outputLimit) usage.outputLimit = parsed.part.context.outputLimit;
91
- // Track peak context usage: input_tokens (current request) is the context usage for this step
92
- // The actual context used per request = input tokens + cache_read tokens for that request
93
- const stepContextUsage = (tokens.input || 0) + (tokens.cache?.read || 0);
94
- if (stepContextUsage > usage.peakContextUsage) {
95
- usage.peakContextUsage = stepContextUsage;
96
- }
97
- }
98
- }
99
- } catch {
100
- // Skip lines that aren't valid JSON
101
- continue;
102
- }
103
- }
104
-
105
- return usage;
106
- };
107
-
108
33
  /**
109
34
  * Helper function to get original provider name from provider identifier
110
35
  * Used for calculating public pricing estimates based on original provider prices
@@ -221,13 +146,29 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
221
146
  // Calculate public pricing estimate based on original provider prices
222
147
  // Prices are per 1M tokens, so divide by 1,000,000
223
148
  // All priced components from models.dev: input, output, cache_read, cache_write, reasoning
224
- const inputCost = (tokenUsage.inputTokens * (cost.input || 0)) / 1_000_000;
225
- const outputCost = (tokenUsage.outputTokens * (cost.output || 0)) / 1_000_000;
226
- const cacheReadCost = (tokenUsage.cacheReadTokens * (cost.cache_read || 0)) / 1_000_000;
227
- const cacheWriteCost = (tokenUsage.cacheWriteTokens * (cost.cache_write || 0)) / 1_000_000;
228
- const reasoningCost = (tokenUsage.reasoningTokens * (cost.reasoning || 0)) / 1_000_000;
229
-
230
- const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost + reasoningCost;
149
+ const million = new Decimal(1_000_000);
150
+ const inputCost = new Decimal(tokenUsage.inputTokens)
151
+ .mul(cost.input || 0)
152
+ .div(million)
153
+ .toNumber();
154
+ const outputCost = new Decimal(tokenUsage.outputTokens)
155
+ .mul(cost.output || 0)
156
+ .div(million)
157
+ .toNumber();
158
+ const cacheReadCost = new Decimal(tokenUsage.cacheReadTokens)
159
+ .mul(cost.cache_read || 0)
160
+ .div(million)
161
+ .toNumber();
162
+ const cacheWriteCost = new Decimal(tokenUsage.cacheWriteTokens)
163
+ .mul(cost.cache_write || 0)
164
+ .div(million)
165
+ .toNumber();
166
+ const reasoningCost = new Decimal(tokenUsage.reasoningTokens)
167
+ .mul(cost.reasoning || 0)
168
+ .div(million)
169
+ .toNumber();
170
+
171
+ const totalCost = new Decimal(inputCost).plus(outputCost).plus(cacheReadCost).plus(cacheWriteCost).plus(reasoningCost).toNumber();
231
172
 
232
173
  // Determine if this is a free model from OpenCode Zen or Kilo Gateway
233
174
  // Models accessed via OpenCode Zen or Kilo Gateway are free, regardless of original provider pricing
@@ -380,6 +321,9 @@ export const handleAgentRuntimeSwitch = async () => {
380
321
  await log('ℹ️ Agent runtime handling not required for this operation');
381
322
  };
382
323
 
324
+ /** Check if Playwright MCP is available for Agent @returns {Promise<boolean>} */
325
+ export const checkPlaywrightMcpAvailability = checkPlaywrightMcpPackageAvailability;
326
+
383
327
  // Main function to execute Agent with prompts and settings
384
328
  export const executeAgent = async params => {
385
329
  const { issueUrl, issueNumber, prNumber, prUrl, branchName, tempDir, workspaceTmpDir, isContinueMode, mergeStateStatus, forkedRepo, feedbackLines, forkActionsUrl, owner, repo, argv, log, formatAligned, getResourceSnapshot, agentPath = 'agent', $ } = params;
@@ -499,6 +443,19 @@ export const executeAgentCommand = async params => {
499
443
  await log(` Memory: ${resourcesBefore.memory.split('\n')[1]}`, { verbose: true });
500
444
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
501
445
 
446
+ // Issue #1521: Build environment for agent process.
447
+ // Pass LINK_ASSISTANT_AGENT_VERBOSE env var when --verbose is enabled so verbose logging is initialized at module load time.
448
+ const agentEnv = { ...process.env };
449
+ if (argv.verbose) {
450
+ agentEnv.LINK_ASSISTANT_AGENT_VERBOSE = 'true';
451
+ }
452
+
453
+ // Apply Playwright MCP session state before launching Agent.
454
+ if (argv.playwrightMcp === false) {
455
+ Object.assign(agentEnv, await getAgentPlaywrightMcpDisableEnv({ env: agentEnv, cwd: tempDir, log }));
456
+ await log('🎭 Playwright MCP physically disabled for this Agent session via --no-playwright-mcp', { verbose: true });
457
+ }
458
+
502
459
  // Build Agent command
503
460
  let execCommand;
504
461
 
@@ -533,17 +490,6 @@ export const executeAgentCommand = async params => {
533
490
  // Pipe the prompt file to agent via stdin
534
491
  // Use agentArgs which includes --model and optionally --verbose
535
492
 
536
- // Issue #1521: Build environment for agent process
537
- // Pass LINK_ASSISTANT_AGENT_VERBOSE env var when --verbose is enabled
538
- // This ensures Flag.LINK_ASSISTANT_AGENT_VERBOSE is true at module load time inside the agent,
539
- // which is required for HTTP request/response logging to work.
540
- // The --verbose CLI flag alone is not sufficient because the agent's Flag module
541
- // reads the env var at initialization, before yargs middleware calls Flag.setVerbose().
542
- const agentEnv = { ...process.env };
543
- if (argv.verbose) {
544
- agentEnv.LINK_ASSISTANT_AGENT_VERBOSE = 'true';
545
- }
546
-
547
493
  execCommand = $({
548
494
  cwd: tempDir,
549
495
  mirror: false,
@@ -576,52 +522,8 @@ export const executeAgentCommand = async params => {
576
522
  let agentCompletedSuccessfully = false;
577
523
  // Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
578
524
  // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
579
- const streamingTokenUsage = {
580
- inputTokens: 0,
581
- outputTokens: 0,
582
- reasoningTokens: 0,
583
- cacheReadTokens: 0,
584
- cacheWriteTokens: 0,
585
- totalCost: 0,
586
- stepCount: 0,
587
- // Issue #1526: Track model and context info from step_finish events
588
- requestedModelId: null,
589
- respondedModelId: null,
590
- contextLimit: null,
591
- outputLimit: null,
592
- peakContextUsage: 0,
593
- };
594
- // Helper to accumulate tokens from step_finish events during streaming
595
- const accumulateTokenUsage = data => {
596
- if (data.type === 'step_finish' && data.part?.tokens) {
597
- const tokens = data.part.tokens;
598
- streamingTokenUsage.stepCount++;
599
- if (tokens.input) streamingTokenUsage.inputTokens += tokens.input;
600
- if (tokens.output) streamingTokenUsage.outputTokens += tokens.output;
601
- if (tokens.reasoning) streamingTokenUsage.reasoningTokens += tokens.reasoning;
602
- if (tokens.cache) {
603
- if (tokens.cache.read) streamingTokenUsage.cacheReadTokens += tokens.cache.read;
604
- if (tokens.cache.write) streamingTokenUsage.cacheWriteTokens += tokens.cache.write;
605
- }
606
- if (data.part.cost !== undefined) {
607
- streamingTokenUsage.totalCost += data.part.cost;
608
- }
609
- // Issue #1526: Extract model info from step_finish events
610
- if (data.part.model) {
611
- if (data.part.model.requestedModelID) streamingTokenUsage.requestedModelId = data.part.model.requestedModelID;
612
- if (data.part.model.respondedModelID) streamingTokenUsage.respondedModelId = data.part.model.respondedModelID;
613
- }
614
- // Issue #1526: Extract context limits and track peak context usage
615
- if (data.part.context) {
616
- if (data.part.context.contextLimit) streamingTokenUsage.contextLimit = data.part.context.contextLimit;
617
- if (data.part.context.outputLimit) streamingTokenUsage.outputLimit = data.part.context.outputLimit;
618
- const stepContextUsage = (tokens.input || 0) + (tokens.cache?.read || 0);
619
- if (stepContextUsage > streamingTokenUsage.peakContextUsage) {
620
- streamingTokenUsage.peakContextUsage = stepContextUsage;
621
- }
622
- }
623
- }
624
- };
525
+ const streamingTokenUsage = createAgentTokenUsage();
526
+ const accumulateTokenUsage = data => accumulateAgentStepFinishUsage(streamingTokenUsage, data);
625
527
 
626
528
  for await (const chunk of execCommand.stream()) {
627
529
  if (chunk.type === 'stdout') {
@@ -980,8 +882,10 @@ export const executeAgentCommand = async params => {
980
882
  if (tokenUsage.reasoningTokens > 0) {
981
883
  await log(` Reasoning tokens: ${tokenUsage.reasoningTokens.toLocaleString()}`);
982
884
  }
983
- if (tokenUsage.cacheReadTokens > 0 || tokenUsage.cacheWriteTokens > 0) {
885
+ if (tokenUsage.cacheReadTokens > 0 || tokenUsage.tokenFieldAvailability?.cacheReadTokens) {
984
886
  await log(` Cache read: ${tokenUsage.cacheReadTokens.toLocaleString()}`);
887
+ }
888
+ if (tokenUsage.cacheWriteTokens > 0 || tokenUsage.tokenFieldAvailability?.cacheWriteTokens) {
985
889
  await log(` Cache write: ${tokenUsage.cacheWriteTokens.toLocaleString()}`);
986
890
  }
987
891
 
@@ -1143,6 +1047,7 @@ export const checkForUncommittedChanges = async (tempDir, owner, repo, branchNam
1143
1047
  export default {
1144
1048
  validateAgentConnection,
1145
1049
  handleAgentRuntimeSwitch,
1050
+ checkPlaywrightMcpAvailability,
1146
1051
  executeAgent,
1147
1052
  executeAgentCommand,
1148
1053
  checkForUncommittedChanges,
@@ -225,6 +225,24 @@ GitHub CLI command patterns.
225
225
  - When adding issue comment, use gh issue comment NUMBER --body "text" --repo OWNER/REPO.
226
226
  - When viewing PR details, use gh pr view NUMBER --repo OWNER/REPO.
227
227
  - When filtering with jq, use gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate --jq 'reverse | .[0:5]'.${
228
+ argv && argv.promptPlaywrightMcp
229
+ ? `
230
+
231
+ Playwright MCP usage (browser automation via MCP tools).
232
+ - When you develop frontend web applications (HTML, CSS, JavaScript, React, Vue, Angular, etc.), use Playwright MCP tools to test the UI in a real browser.
233
+ - When WebFetch tool fails to retrieve expected content (e.g., returns empty content, JavaScript-rendered pages, or login-protected pages), use Playwright MCP tools (browser_navigate, browser_snapshot) as a fallback for web browsing.
234
+ - When WebSearch tool fails or returns insufficient results, use Playwright MCP tools (browser_navigate, browser_snapshot) as a fallback for internet search.
235
+ - When you need to interact with dynamic web pages that require JavaScript execution, use Playwright MCP tools.
236
+ - When you need to visually verify how a web page looks or take screenshots, use browser_take_screenshot from Playwright MCP.
237
+ - When you need to fill forms, click buttons, or perform user interactions on web pages, use Playwright MCP tools (browser_click, browser_type, browser_fill_form).
238
+ - When you need to test responsive design or different viewport sizes, use browser_resize from Playwright MCP.
239
+ - When you finish using the browser, close it with browser_close to free resources.
240
+ - When reproducing UI bugs, use browser_take_screenshot to capture the problem state before implementing any fix.
241
+ - When fixing UI bugs, take before/after screenshots to provide visual evidence of the fix for human verification.
242
+ - When creating UI tests, save baseline screenshots to the repository for visual regression testing.
243
+ - When verifying UI fixes, compare screenshots to ensure the fix does not introduce unintended visual changes.`
244
+ : ''
245
+ }${
228
246
  modelSupportsVision
229
247
  ? `
230
248
 
@@ -3,6 +3,7 @@
3
3
  // Extracted from claude.lib.mjs to maintain file line limits
4
4
 
5
5
  import { formatNumber } from './claude.lib.mjs';
6
+ import Decimal from 'decimal.js-light';
6
7
 
7
8
  /**
8
9
  * Helper: creates a fresh sub-session usage object for tracking tokens between compactification events
@@ -105,11 +106,11 @@ export const displayModelUsage = async (usage, log) => {
105
106
  ];
106
107
  for (const { key, label } of types) {
107
108
  if (breakdown[key].tokens > 0) {
108
- await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens × $${breakdown[key].costPerMillion}/M = $${breakdown[key].cost.toFixed(6)}`);
109
+ await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens × $${breakdown[key].costPerMillion}/M = $${new Decimal(breakdown[key].cost).toFixed(6)}`);
109
110
  }
110
111
  }
111
112
  await log(' ─────────────────────────────────');
112
- await log(` Total: $${usage.costUSD.toFixed(6)}`);
113
+ await log(` Total: $${new Decimal(usage.costUSD).toFixed(6)}`);
113
114
  } else if (usage.modelInfo === null) {
114
115
  await log('');
115
116
  await log(' Cost: Not available (could not fetch pricing)');
@@ -126,18 +127,19 @@ export const displayModelUsage = async (usage, log) => {
126
127
  export const displayCostComparison = async (publicCost, anthropicCost, log) => {
127
128
  const hasPublic = publicCost !== null && publicCost !== undefined;
128
129
  const hasAnthropic = anthropicCost !== null && anthropicCost !== undefined;
129
- // Issue #1557: When both costs match, show simplified format
130
- if (hasPublic && hasAnthropic && publicCost.toFixed(6) === anthropicCost.toFixed(6)) {
131
- await log(`\n 💰 Cost: $${anthropicCost.toFixed(6)}`);
130
+ const publicDec = hasPublic ? new Decimal(publicCost) : null;
131
+ const anthropicDec = hasAnthropic ? new Decimal(anthropicCost) : null;
132
+ if (publicDec && anthropicDec && publicDec.toFixed(6) === anthropicDec.toFixed(6)) {
133
+ await log(`\n 💰 Cost: $${anthropicDec.toFixed(6)}`);
132
134
  return;
133
135
  }
134
136
  await log('\n 💰 Cost estimation:');
135
- await log(` Public pricing estimate: ${hasPublic ? `$${publicCost.toFixed(6)}` : 'unknown'}`);
136
- await log(` Calculated by Anthropic: ${hasAnthropic ? `$${anthropicCost.toFixed(6)}` : 'unknown'}`);
137
- if (hasPublic && hasAnthropic) {
138
- const difference = anthropicCost - publicCost;
139
- const percentDiff = publicCost > 0 ? (difference / publicCost) * 100 : 0;
140
- await log(` Difference: $${difference.toFixed(6)} (${percentDiff > 0 ? '+' : ''}${percentDiff.toFixed(2)}%)`);
137
+ await log(` Public pricing estimate: ${publicDec ? `$${publicDec.toFixed(6)}` : 'unknown'}`);
138
+ await log(` Calculated by Anthropic: ${anthropicDec ? `$${anthropicDec.toFixed(6)}` : 'unknown'}`);
139
+ if (publicDec && anthropicDec) {
140
+ const difference = anthropicDec.minus(publicDec);
141
+ const percentDiff = publicDec.gt(0) ? difference.div(publicDec).mul(100) : new Decimal(0);
142
+ await log(` Difference: $${difference.toFixed(6)} (${percentDiff.gt(0) ? '+' : ''}${percentDiff.toFixed(2)}%)`);
141
143
  } else {
142
144
  await log(' Difference: unknown');
143
145
  }
@@ -169,11 +171,10 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
169
171
  const peakContext = usage.peakContextUsage || 0;
170
172
 
171
173
  if (hasMultipleSubSessions) {
174
+ // Issue #1600: Unified format — numbered list without "Context window:" prefix
172
175
  for (let i = 0; i < subSessions.length; i++) {
173
176
  const sub = subSessions[i];
174
177
  const subPeak = sub.peakContextUsage || 0;
175
- // Issue #1539: Only use peak per-request context for context window display.
176
- // Issue #1547: Percentage before unit label: X / Y (Z%) input tokens
177
178
  const parts = [];
178
179
  if (contextLimit && subPeak > 0) {
179
180
  const pct = ((subPeak / contextLimit) * 100).toFixed(0);
@@ -184,12 +185,10 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
184
185
  parts.push(`${formatNumber(sub.outputTokens)} / ${formatNumber(outputLimit)} (${outPct}%) output tokens`);
185
186
  }
186
187
  if (parts.length > 0) {
187
- await log(` ${i + 1}. Context window: ${parts.join(', ')}`);
188
+ await log(` ${i + 1}. ${parts.join(', ')}`);
188
189
  }
189
190
  }
190
191
  } else if (peakContext > 0) {
191
- // Single sub-session with known peak: single-line format
192
- // Issue #1547: Percentage before unit label
193
192
  const parts = [];
194
193
  if (contextLimit) {
195
194
  const pct = ((peakContext / contextLimit) * 100).toFixed(0);
@@ -200,11 +199,9 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
200
199
  parts.push(`${formatNumber(usage.outputTokens)} / ${formatNumber(outputLimit)} (${outPct}%) output tokens`);
201
200
  }
202
201
  if (parts.length > 0) {
203
- await log(` Context window: ${parts.join(', ')}`);
202
+ await log(` - ${parts.join(', ')}`);
204
203
  }
205
204
  }
206
- // Issue #1539: When peakContextUsage is unknown, skip context window line entirely.
207
- // Cumulative totals are shown on the Total line below — no duplication needed.
208
205
 
209
206
  // Cumulative totals — single line
210
207
  // Issue #1547: Parenthesized cached format and consistent output format
@@ -303,14 +300,13 @@ const formatTokensCompact = tokens => {
303
300
  * @returns {string} Formatted sub-sessions string
304
301
  */
305
302
  /**
306
- * Issue #1526: Format sub-sessions list using numbered single-line format.
307
- * Each sub-session gets: "N. Context window: X / Y input tokens (Z%), A / B output tokens (W%)"
303
+ * Issue #1600: Format sub-sessions list using numbered single-line format.
304
+ * Each sub-session gets: "N. X / Y (Z%) input tokens, A / B (W%) output tokens"
308
305
  */
309
306
  const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
310
307
  let result = '';
311
308
  for (let i = 0; i < subSessions.length; i++) {
312
309
  const sub = subSessions[i];
313
- // Issue #1539: Only use peak per-request context; skip context display when unknown
314
310
  const subPeakContext = sub.peakContextUsage || 0;
315
311
  result += formatContextOutputLine(subPeakContext, contextLimit, sub.outputTokens, outputLimit, `${i + 1}. `);
316
312
  }
@@ -318,10 +314,7 @@ const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
318
314
  };
319
315
 
320
316
  /**
321
- * Issue #1526: Build a single-line context window + output tokens string.
322
- * Issue #1539: Only show context window when peakContext > 0 (per-request peak known).
323
- * When peakContext is 0 (unknown), context part is omitted to avoid misleading percentages.
324
- * Format: "- Context window: X / Y input tokens (Z%), A / B output tokens (W%)"
317
+ * Issue #1600: Build a single-line context + output tokens string (unified format, no "Context window:" prefix).
325
318
  * @param {number} peakContext - Peak context usage (0 if unknown — context display skipped)
326
319
  * @param {number} contextLimit - Context window limit (null if unknown)
327
320
  * @param {number} outputTokens - Output tokens used
@@ -331,22 +324,16 @@ const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
331
324
  */
332
325
  const formatContextOutputLine = (peakContext, contextLimit, outputTokens, outputLimit, prefix = '- ') => {
333
326
  const parts = [];
334
- if (contextLimit) {
335
- // Issue #1539: Only use peak per-request context for context window display.
336
- // When peak is unknown (e.g., model only from result JSON, not in JSONL),
337
- // skip context display. Cumulative totals across all requests are not valid
338
- // context window metrics and produce impossible percentages (e.g. 250%).
339
- if (peakContext > 0) {
340
- const pct = ((peakContext / contextLimit) * 100).toFixed(0);
341
- parts.push(`${formatTokensCompact(peakContext)} / ${formatTokensCompact(contextLimit)} (${pct}%) input tokens`);
342
- }
327
+ if (contextLimit && peakContext > 0) {
328
+ const pct = ((peakContext / contextLimit) * 100).toFixed(0);
329
+ parts.push(`${formatTokensCompact(peakContext)} / ${formatTokensCompact(contextLimit)} (${pct}%) input tokens`);
343
330
  }
344
331
  if (outputLimit) {
345
332
  const outPct = ((outputTokens / outputLimit) * 100).toFixed(0);
346
333
  parts.push(`${formatTokensCompact(outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`);
347
334
  }
348
335
  if (parts.length === 0) return '';
349
- return `\n${prefix}Context window: ${parts.join(', ')}`;
336
+ return `\n${prefix}${parts.join(', ')}`;
350
337
  };
351
338
 
352
339
  /**
@@ -445,31 +432,37 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
445
432
 
446
433
  // Issue #1590: Check if this model was used as a sub-agent
447
434
  const callCount = getSubAgentCallCount(modelId, subAgentCallCounts);
435
+ const isPrimaryModel = !isMultiModel || modelId === modelIds[0];
436
+ const showSubSessions = hasMultipleSubSessions && isPrimaryModel;
448
437
 
449
438
  if (isMultiModel) {
450
439
  // Issue #1590: Show sub-agent call count alongside model name
440
+ // Issue #1600: Show session segment count for primary model
451
441
  if (callCount > 1) {
452
442
  stats += `\n\n**${modelName}:** (${callCount} sub-agent calls)`;
443
+ } else if (showSubSessions) {
444
+ stats += `\n\n**${modelName}:** (${subSessions.length} session segments)`;
453
445
  } else {
454
446
  stats += `\n\n**${modelName}:**`;
455
447
  }
448
+ } else if (showSubSessions) {
449
+ stats += `\n\n**${modelName}:** (${subSessions.length} session segments)`;
456
450
  }
457
451
 
458
452
  const peakContext = usage.peakContextUsage || 0;
459
453
 
460
- if (hasMultipleSubSessions && (!isMultiModel || modelId === modelIds[0])) {
461
- // Issue #1547: Show sub-sessions under the primary model heading (not globally).
462
- // For single-model sessions, show under that model. For multi-model, under the first model.
454
+ if (showSubSessions) {
455
+ // Issue #1600: Unified format no "Context window:" prefix, same format as sub-agent calls
463
456
  stats += formatSubSessionsList(subSessions, contextLimit, outputLimit);
464
457
  } else if (peakContext > 0) {
465
- // Issue #1526: Single line format for context window + output tokens
466
458
  stats += formatContextOutputLine(peakContext, contextLimit, usage.outputTokens, outputLimit, '- ');
459
+ } else if (outputLimit && callCount <= 1) {
460
+ // Issue #1600: Show output-only detalization for sub-agent single sessions
461
+ const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
462
+ stats += `\n- ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`;
467
463
  }
468
- // Issue #1539: When peakContextUsage is unknown, skip context window line entirely.
469
- // Cumulative totals are shown on the Total line below — no duplication needed.
470
464
 
471
465
  // Cumulative totals per model: input tokens + cached shown separately
472
- // Issue #1547: Parenthesized cached format: (X + Y cached) input tokens
473
466
  const totalInputNonCached = usage.inputTokens + usage.cacheCreationTokens;
474
467
  const cachedTokens = usage.cacheReadTokens;
475
468
  let totalLine;
@@ -479,36 +472,25 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
479
472
  totalLine = `${formatTokensCompact(totalInputNonCached)} input tokens`;
480
473
  }
481
474
 
482
- // Issue #1547: Consistent output format use X / Y (Z%) output tokens when limit known
483
- // Issue #1590: When multiple sub-agent calls exist, show total output without misleading
484
- // per-call percentage (e.g., 530% is sum across 12 calls, not a single call)
485
- if (peakContext === 0 && outputLimit) {
486
- if (callCount > 1) {
487
- // Show total output without percentage (percentage is misleading for aggregated sub-agent calls)
488
- totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
489
- } else {
490
- const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
491
- totalLine += `, ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`;
492
- }
475
+ // Issue #1600: Output tokens on Total line skip percentage if already shown above or aggregated
476
+ if (callCount > 1) {
477
+ totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
493
478
  } else {
494
479
  totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
495
480
  }
496
481
 
497
- // Issue #1508: Show per-model cost when available
482
+ // Issue #1600: Use Decimal for cost display precision
498
483
  if (usage.costUSD !== null && usage.costUSD !== undefined) {
499
- totalLine += `, $${usage.costUSD.toFixed(6)} cost`;
484
+ totalLine += `, $${new Decimal(usage.costUSD).toFixed(6)} cost`;
500
485
  }
501
486
 
502
487
  // Issue #1590: Show individual sub-agent call list when multiple calls exist
503
- // Total line appears AFTER the sub-agent calls list (not before)
504
488
  if (callCount > 1) {
505
489
  const matchingCalls = getSubAgentCallsForModel(modelId, validSubAgentCalls);
506
- // Issue #1590: Check if actual per-call usage data is available from parent_tool_use_id tracking
507
490
  const hasActualUsage = matchingCalls.some(c => c.usage && (c.usage.inputTokens > 0 || c.usage.outputTokens > 0 || c.usage.cacheReadTokens > 0 || c.usage.cacheCreationTokens > 0));
508
491
 
509
492
  stats += `\n\nSub-agent calls:`;
510
493
  if (hasActualUsage) {
511
- // Show actual per-call usage with limits and percentages (same format as sub-sessions)
512
494
  for (let i = 0; i < matchingCalls.length; i++) {
513
495
  const call = matchingCalls[i];
514
496
  const cu = call.usage || {};
@@ -530,7 +512,6 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
530
512
  stats += `\n${i + 1}. ${parts.join(', ')}`;
531
513
  }
532
514
  } else {
533
- // Fallback: show estimates with limits and percentages when actual per-call data is not available
534
515
  const avgInput = Math.round((totalInputNonCached + cachedTokens) / callCount);
535
516
  const avgOutput = Math.round(usage.outputTokens / callCount);
536
517
  for (let i = 0; i < matchingCalls.length; i++) {
@@ -549,7 +530,6 @@ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
549
530
  }
550
531
  stats += `\n${i + 1}. ${parts.join(', ')}`;
551
532
  }
552
- // Note about estimates only when using fallback
553
533
  stats += `\n\n_Per-call values are estimates (total ÷ ${callCount}). Exact per-call breakdown requires [upstream support](https://github.com/anthropics/claude-code/issues/46520)._`;
554
534
  }
555
535
  }