Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { handleHookInput } from './utils/hook-handler';
import {
getSessionDuration,
getSpeedMetricsCollection,
getSubagentCostUsd,
getTokenMetrics
} from './utils/jsonl';
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
Expand Down Expand Up @@ -127,6 +128,12 @@ async function renderMultipleLines(data: StatusJSON) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
}

const hasCostTotal = lines.some(line => line.some(item => item.type === 'session-cost-total'));
let subagentCostUsd: number | null = null;
if (hasCostTotal && data.transcript_path) {
subagentCostUsd = await getSubagentCostUsd(data.transcript_path);
}

let sessionDuration: string | null = null;
if (hasSessionClock && !hasSessionDurationInStatusJson(data) && data.transcript_path) {
sessionDuration = await getSessionDuration(data.transcript_path);
Expand Down Expand Up @@ -161,6 +168,7 @@ async function renderMultipleLines(data: StatusJSON) {
const context: RenderContext = {
data,
tokenMetrics,
subagentCostUsd,
speedMetrics,
windowedSpeedMetrics,
usageData,
Expand Down
1 change: 1 addition & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CompactionData {
export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
subagentCostUsd?: number | null;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
Expand Down
2 changes: 1 addition & 1 deletion src/types/TokenMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface TokenUsage {
}

export interface TranscriptLine {
message?: { usage?: TokenUsage; stop_reason?: string | null };
message?: { usage?: TokenUsage; stop_reason?: string | null; model?: string };
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
Expand Down
34 changes: 34 additions & 0 deletions src/utils/__tests__/jsonl-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getSessionDuration,
getSpeedMetrics,
getSpeedMetricsCollection,
getSubagentCostUsd,
getTokenMetrics
} from '../jsonl';

Expand Down Expand Up @@ -1098,4 +1099,37 @@ describe('jsonl transcript metrics', () => {
requestCount: 0
});
});

it('sums subagent cost from referenced subagent transcripts (priced by model)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-cost-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'session.jsonl');
fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'assistant', message: { content: [{ agentId: 'aaa' }] } }));

const subagentsDir = path.join(root, 'subagents');
fs.mkdirSync(subagentsDir);
// 1M input + 1M output on Opus = $5 + $25 = $30
fs.writeFileSync(path.join(subagentsDir, 'agent-aaa.jsonl'), JSON.stringify({
type: 'assistant',
message: {
model: 'claude-opus-4-8',
stop_reason: 'end_turn',
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 }
}
}));

expect(await getSubagentCostUsd(transcriptPath)).toBeCloseTo(30, 6);
});

it('returns 0 subagent cost when none are referenced', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-cost-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'session.jsonl');
fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'user' }));
expect(await getSubagentCostUsd(transcriptPath)).toBe(0);
});

it('returns 0 subagent cost when transcript is missing', async () => {
expect(await getSubagentCostUsd('/tmp/ccstatusline-jsonl-cost-missing.jsonl')).toBe(0);
});
});
41 changes: 41 additions & 0 deletions src/utils/__tests__/model-pricing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
describe,
expect,
it
} from 'vitest';

import { costForUsage } from '../model-pricing';

describe('costForUsage', () => {
it('prices Opus input + output at $5/$25 per MTok', () => {
const cost = costForUsage('claude-opus-4-8', {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 0,
cacheCreationTokens: 0
});
expect(cost).toBeCloseTo(30, 6); // 5 + 25
});

it('prices Sonnet and Haiku by family', () => {
const sonnet = costForUsage('claude-sonnet-4-6', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
const haiku = costForUsage('claude-haiku-4-5', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
expect(sonnet).toBeCloseTo(3, 6);
expect(haiku).toBeCloseTo(1, 6);
});

it('applies cache multipliers (1.25x write, 0.1x read) off input price', () => {
const cost = costForUsage('claude-opus-4-8', {
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000
});
expect(cost).toBeCloseTo(5 * 1.25 + 5 * 0.1, 6); // 6.25 + 0.5
});

it('falls back to Opus-tier pricing for unknown models', () => {
const cost = costForUsage('some-unknown-model', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
expect(cost).toBeCloseTo(5, 6);
});
});
68 changes: 68 additions & 0 deletions src/utils/jsonl-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
parseJsonlLine,
readJsonlLines
} from './jsonl-lines';
import { costForUsage } from './model-pricing';

export interface SpeedMetricsOptions {
includeSubagents?: boolean;
Expand Down Expand Up @@ -490,6 +491,73 @@ function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds:
return matchedPaths;
}

// Sum the USD cost of one subagent transcript from its per-call token usage.
// Mirrors getTokenMetrics' streaming dedup: when entries carry stop_reason,
// count finalized entries plus the latest unfinished one; otherwise count all.
function subagentCostFromLines(lines: string[]): number {
const parsedEntries: TranscriptLine[] = [];
let hasStopReasonField = false;

for (const line of lines) {
const data = parseJsonlLine(line) as TranscriptLine | null;
if (data?.message?.usage && !data.isApiErrorMessage) {
parsedEntries.push(data);
if (Object.hasOwn(data.message, 'stop_reason')) {
hasStopReasonField = true;
}
}
}

const entriesToCount = hasStopReasonField
? parsedEntries.filter((data, index) => {
const stopReason = data.message?.stop_reason;
return Boolean(stopReason) || (stopReason === null && index === parsedEntries.length - 1);
})
: parsedEntries;

let cost = 0;
for (const data of entriesToCount) {
const usage = data.message?.usage;
if (!usage) {
continue;
}
cost += costForUsage(data.message?.model ?? '', {
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
cacheCreationTokens: usage.cache_creation_input_tokens ?? 0
});
}
return cost;
}

// Total USD cost of all subagents (Task/Agent tool) referenced this session.
// Claude Code's cost.total_cost_usd covers only the main transcript; this fills
// the gap by pricing the separate subagents/agent-*.jsonl transcripts.
export async function getSubagentCostUsd(transcriptPath: string): Promise<number> {
try {
if (!fs.existsSync(transcriptPath)) {
return 0;
}

const mainLines = await readJsonlLines(transcriptPath);
const referencedSubagentIds = getReferencedSubagentIds(mainLines);
const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds);

const costs = await Promise.all(subagentPaths.map(async (subagentPath) => {
try {
return subagentCostFromLines(await readJsonlLines(subagentPath));
} catch {
return 0;
}
}));

return costs.reduce((sum, c) => sum + c, 0);
} catch {
return 0;
}
}

export async function getSpeedMetricsCollection(
transcriptPath: string,
options: SpeedMetricsCollectionOptions = {}
Expand Down
1 change: 1 addition & 0 deletions src/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
getSessionDuration,
getSpeedMetrics,
getSpeedMetricsCollection,
getSubagentCostUsd,
getTokenMetrics
} from './jsonl-metrics';
export {
Expand Down
46 changes: 46 additions & 0 deletions src/utils/model-pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Per-million-token USD prices by model family. Source: Anthropic pricing
// (input / output). Cache write is billed at 1.25x input (5m TTL), cache read
// at 0.1x input — the standard ephemeral-cache multipliers.
interface ModelPrice {
inputPerMTok: number;
outputPerMTok: number;
}

const PRICES: { match: (id: string) => boolean; price: ModelPrice }[] = [
{ match: id => id.includes('fable') || id.includes('mythos'), price: { inputPerMTok: 10, outputPerMTok: 50 } },
{ match: id => id.includes('haiku'), price: { inputPerMTok: 1, outputPerMTok: 5 } },
{ match: id => id.includes('sonnet'), price: { inputPerMTok: 3, outputPerMTok: 15 } },
{ match: id => id.includes('opus'), price: { inputPerMTok: 5, outputPerMTok: 25 } }
];

// Default to Opus-tier pricing for unknown models (Claude Code's default tier).
const DEFAULT_PRICE: ModelPrice = { inputPerMTok: 5, outputPerMTok: 25 };

function priceFor(modelId: string): ModelPrice {
const id = modelId.toLowerCase();
for (const entry of PRICES) {
if (entry.match(id)) {
return entry.price;
}
}
return DEFAULT_PRICE;
}

export interface ModelUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
}

export function costForUsage(modelId: string, usage: ModelUsage): number {
const price = priceFor(modelId);
const inPerTok = price.inputPerMTok / 1_000_000;
const outPerTok = price.outputPerMTok / 1_000_000;
return (
usage.inputTokens * inPerTok
+ usage.outputTokens * outPerTok
+ usage.cacheCreationTokens * inPerTok * 1.25
+ usage.cacheReadTokens * inPerTok * 0.1
);
}
1 change: 1 addition & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() },
{ type: 'session-clock', create: () => new widgets.SessionClockWidget() },
{ type: 'session-cost', create: () => new widgets.SessionCostWidget() },
{ type: 'session-cost-total', create: () => new widgets.SessionCostTotalWidget() },
{ type: 'block-timer', create: () => new widgets.BlockTimerWidget() },
{ type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() },
{ type: 'version', create: () => new widgets.VersionWidget() },
Expand Down
49 changes: 49 additions & 0 deletions src/widgets/SessionCostTotal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';

// Session cost including subagents. Claude Code's cost.total_cost_usd covers
// only the main transcript; this widget adds the cost of Task/Agent subagents
// (priced from their separate transcripts) so heavy parallel-agent sessions
// show a realistic total.
export class SessionCostTotalWidget implements Widget {
getDefaultColor(): string { return 'green'; }
getDescription(): string { return 'Shows the total session cost in USD including subagents (Task/Agent tool)'; }
getDisplayName(): string { return 'Session Cost (with subagents)'; }
getCategory(): string { return 'Session'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return { displayText: this.getDisplayName() };
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
void settings;
if (context.isPreview) {
return item.rawValue ? '$3.55' : 'Total Cost: $3.55';
}

const mainCost = context.data?.cost?.total_cost_usd;
if (mainCost === undefined) {
return null;
}

const total = mainCost + (context.subagentCostUsd ?? 0);
const formatted = `$${total.toFixed(2)}`;

return item.rawValue ? formatted : `Total Cost: ${formatted}`;
}

getNumericValue(context: RenderContext): number | null {
const mainCost = context.data?.cost?.total_cost_usd;
if (mainCost === undefined) {
return null;
}
return mainCost + (context.subagentCostUsd ?? 0);
}

supportsRawValue(): boolean { return true; }
supportsColors(item: WidgetItem): boolean { return true; }
}
1 change: 1 addition & 0 deletions src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { ContextPercentageWidget } from './ContextPercentage';
export { ContextPercentageUsableWidget } from './ContextPercentageUsable';
export { SessionClockWidget } from './SessionClock';
export { SessionCostWidget } from './SessionCost';
export { SessionCostTotalWidget } from './SessionCostTotal';
export { TerminalWidthWidget } from './TerminalWidth';
export { VersionWidget } from './Version';
export { CustomTextWidget } from './CustomText';
Expand Down