fix(costs): read token usage from Claude Code transcripts instead of tmux (#941)

* fix(costs): read token usage from Claude Code transcripts instead of tmux

Replace tmux screen-scraping with reading token usage directly from Claude
Code transcript files at ~/.claude/projects/*/. This enables accurate cost
tracking by summing input_tokens, cache_creation_input_tokens,
cache_read_input_tokens, and output_tokens from assistant messages.

Adds model-specific pricing for Opus 4.5, Sonnet 4, and Haiku 3.5 to
calculate USD costs from token counts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(costs): correct Claude project path encoding

The leading slash should become a leading dash, not be stripped.
Claude Code encodes /Users/foo as -Users-foo, not Users-foo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(costs): make --by-role and --by-rig fast by defaulting to today's costs

When using --by-role or --by-rig without a time filter, the command was
querying all historical events from the beads database via expensive bd list
and bd show commands, taking 10+ seconds and returning no data.

Now these flags default to today's costs from the log file (same as --today),
making them fast and showing actual data. This aligns with the new log-file-based
cost tracking architecture.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Harrison
2026-01-25 20:06:22 -06:00
committed by GitHub
parent c94a2301eb
commit a86c7d954f

View File

@@ -2,8 +2,10 @@
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -44,22 +46,11 @@ var (
var costsCmd = &cobra.Command{
Use: "costs",
GroupID: GroupDiag,
Short: "Show costs for running Claude sessions [DISABLED]",
Short: "Show costs for running Claude sessions",
Long: `Display costs for Claude Code sessions in Gas Town.
⚠️ COST TRACKING IS CURRENTLY DISABLED
Claude Code displays costs in the TUI status bar, which cannot be captured
via tmux. All sessions will show $0.00 until Claude Code exposes cost data
through an API or environment variable.
What we need from Claude Code:
- Stop hook env var (e.g., $CLAUDE_SESSION_COST)
- Or queryable file/API endpoint
See: GH#24, gt-7awfj
The infrastructure remains in place and will work once cost data is available.
Costs are calculated from Claude Code transcript files at ~/.claude/projects/
by summing token usage from assistant messages and applying model-specific pricing.
Examples:
gt costs # Live costs from running sessions
@@ -68,6 +59,7 @@ Examples:
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
gt costs --by-rig # Breakdown by rig
gt costs --json # Output as JSON
gt costs -v # Show debug output for failures
Subcommands:
gt costs record # Record session cost to local log file (Stop hook)
@@ -81,7 +73,8 @@ var costsRecordCmd = &cobra.Command{
Long: `Record the final cost of a session to a local log file.
This command is intended to be called from a Claude Code Stop hook.
It captures the final cost from the tmux session and appends it to
It reads token usage from the Claude Code transcript file (~/.claude/projects/...)
and calculates the cost based on model pricing, then appends it to
~/.gt/costs.jsonl. This is a simple append operation that never fails
due to database availability.
@@ -193,6 +186,56 @@ type CostsOutput struct {
// costRegex matches cost patterns like "$1.23" or "$12.34"
var costRegex = regexp.MustCompile(`\$(\d+\.\d{2})`)
// TranscriptMessage represents a message from a Claude Code transcript file.
type TranscriptMessage struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
CWD string `json:"cwd"`
Message *TranscriptMessageBody `json:"message,omitempty"`
}
// TranscriptMessageBody contains the message content and usage info.
type TranscriptMessageBody struct {
Model string `json:"model"`
Role string `json:"role"`
Usage *TranscriptUsage `json:"usage,omitempty"`
}
// TranscriptUsage contains token usage information.
type TranscriptUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// TokenUsage aggregates token usage across a session.
type TokenUsage struct {
Model string
InputTokens int
CacheCreationInputTokens int
CacheReadInputTokens int
OutputTokens int
}
// Model pricing per million tokens (as of Jan 2025).
// See: https://www.anthropic.com/pricing
var modelPricing = map[string]struct {
InputPerMillion float64
OutputPerMillion float64
CacheReadPerMillion float64 // 90% discount on input price
CacheCreatePerMillion float64 // 25% premium on input price
}{
// Claude Opus 4.5
"claude-opus-4-5-20251101": {15.0, 75.0, 1.5, 18.75},
// Claude Sonnet 4
"claude-sonnet-4-20250514": {3.0, 15.0, 0.3, 3.75},
// Claude Haiku 3.5
"claude-3-5-haiku-20241022": {1.0, 5.0, 0.1, 1.25},
// Fallback for unknown models (use Sonnet pricing)
"default": {3.0, 15.0, 0.3, 3.75},
}
func runCosts(cmd *cobra.Command, args []string) error {
// If querying ledger, use ledger functions
if costsToday || costsWeek || costsByRole || costsByRig {
@@ -204,11 +247,6 @@ func runCosts(cmd *cobra.Command, args []string) error {
}
func runLiveCosts() error {
// Warn that cost tracking is disabled
fmt.Fprintf(os.Stderr, "%s Cost tracking is disabled - Claude Code does not expose session costs.\n",
style.Warning.Render("⚠"))
fmt.Fprintf(os.Stderr, " All sessions will show $0.00. See: GH#24, gt-7awfj\n\n")
t := tmux.NewTmux()
// Get all tmux sessions
@@ -229,14 +267,24 @@ func runLiveCosts() error {
// Parse session name to get role/rig/worker
role, rig, worker := parseSessionName(session)
// Capture pane content
content, err := t.CapturePaneAll(session)
// Get working directory of the session
workDir, err := getTmuxSessionWorkDir(session)
if err != nil {
continue // Skip sessions we can't capture
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] could not get workdir for %s: %v\n", session, err)
}
continue
}
// Extract cost from content
cost := extractCost(content)
// Extract cost from Claude transcript
cost, err := extractCostFromWorkDir(workDir)
if err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] could not extract cost for %s: %v\n", session, err)
}
// Still include the session with zero cost
cost = 0.0
}
// Check if an agent appears to be running
running := t.IsAgentRunning(session)
@@ -268,11 +316,6 @@ func runLiveCosts() error {
}
func runCostsFromLedger() error {
// Warn that cost tracking is disabled
fmt.Fprintf(os.Stderr, "%s Cost tracking is disabled - Claude Code does not expose session costs.\n",
style.Warning.Render("⚠"))
fmt.Fprintf(os.Stderr, " Historical data may show $0.00 for all sessions. See: GH#24, gt-7awfj\n\n")
now := time.Now()
var entries []CostEntry
var err error
@@ -295,8 +338,15 @@ func runCostsFromLedger() error {
// Also include today's wisps (not yet digested)
todayEntries, _ := querySessionCostEntries(now)
entries = append(entries, todayEntries...)
} else if costsByRole || costsByRig {
// When using --by-role or --by-rig without time filter, default to today
// (querying all historical events would be expensive and likely empty)
entries, err = querySessionCostEntries(now)
if err != nil {
return fmt.Errorf("querying session cost entries: %w", err)
}
} else {
// No time filter: query both digests and legacy session.ended events
// No time filter and no breakdown flags: query both digests and legacy session.ended events
// (for backwards compatibility during migration)
entries = querySessionEvents()
}
@@ -637,7 +687,9 @@ func parseSessionName(session string) (role, rig, worker string) {
}
// extractCost finds the most recent cost value in pane content.
// Claude Code displays cost in the format "$X.XX" in the status area.
// DEPRECATED: Claude Code no longer displays cost in a scrapable format.
// This is kept for backwards compatibility but always returns 0.0.
// Use extractCostFromTranscript instead.
func extractCost(content string) float64 {
matches := costRegex.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
@@ -655,6 +707,156 @@ func extractCost(content string) float64 {
return cost
}
// getClaudeProjectDir returns the Claude Code project directory for a working directory.
// Claude Code stores transcripts in ~/.claude/projects/<path-with-dashes-instead-of-slashes>/
func getClaudeProjectDir(workDir string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
// Convert path to Claude's directory naming: replace / with -
// Keep leading slash - it becomes a leading dash in Claude's encoding
projectName := strings.ReplaceAll(workDir, "/", "-")
return filepath.Join(home, ".claude", "projects", projectName), nil
}
// findLatestTranscript finds the most recently modified .jsonl file in a directory.
func findLatestTranscript(projectDir string) (string, error) {
var latestPath string
var latestTime time.Time
err := filepath.WalkDir(projectDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && path != projectDir {
return fs.SkipDir // Don't recurse into subdirectories
}
if !d.IsDir() && strings.HasSuffix(path, ".jsonl") {
info, err := d.Info()
if err != nil {
return nil // Skip files we can't stat
}
if info.ModTime().After(latestTime) {
latestTime = info.ModTime()
latestPath = path
}
}
return nil
})
if err != nil {
return "", err
}
if latestPath == "" {
return "", fmt.Errorf("no transcript files found in %s", projectDir)
}
return latestPath, nil
}
// parseTranscriptUsage reads a transcript file and sums token usage from assistant messages.
func parseTranscriptUsage(transcriptPath string) (*TokenUsage, error) {
file, err := os.Open(transcriptPath)
if err != nil {
return nil, err
}
defer file.Close()
usage := &TokenUsage{}
scanner := bufio.NewScanner(file)
// Increase buffer for potentially large JSON lines
buf := make([]byte, 0, 256*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var msg TranscriptMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue // Skip malformed lines
}
// Only process assistant messages with usage info
if msg.Type != "assistant" || msg.Message == nil || msg.Message.Usage == nil {
continue
}
// Capture the model (use first one found, they should all be the same)
if usage.Model == "" && msg.Message.Model != "" {
usage.Model = msg.Message.Model
}
// Sum token usage
u := msg.Message.Usage
usage.InputTokens += u.InputTokens
usage.CacheCreationInputTokens += u.CacheCreationInputTokens
usage.CacheReadInputTokens += u.CacheReadInputTokens
usage.OutputTokens += u.OutputTokens
}
if err := scanner.Err(); err != nil {
return nil, err
}
return usage, nil
}
// calculateCost converts token usage to USD cost based on model pricing.
func calculateCost(usage *TokenUsage) float64 {
if usage == nil {
return 0.0
}
// Look up pricing for the model
pricing, ok := modelPricing[usage.Model]
if !ok {
pricing = modelPricing["default"]
}
// Calculate cost (prices are per million tokens)
inputCost := float64(usage.InputTokens) / 1_000_000 * pricing.InputPerMillion
cacheReadCost := float64(usage.CacheReadInputTokens) / 1_000_000 * pricing.CacheReadPerMillion
cacheCreateCost := float64(usage.CacheCreationInputTokens) / 1_000_000 * pricing.CacheCreatePerMillion
outputCost := float64(usage.OutputTokens) / 1_000_000 * pricing.OutputPerMillion
return inputCost + cacheReadCost + cacheCreateCost + outputCost
}
// extractCostFromWorkDir extracts cost from Claude Code transcript for a working directory.
// This reads the most recent transcript file and sums all token usage.
func extractCostFromWorkDir(workDir string) (float64, error) {
projectDir, err := getClaudeProjectDir(workDir)
if err != nil {
return 0, fmt.Errorf("getting project dir: %w", err)
}
transcriptPath, err := findLatestTranscript(projectDir)
if err != nil {
return 0, fmt.Errorf("finding transcript: %w", err)
}
usage, err := parseTranscriptUsage(transcriptPath)
if err != nil {
return 0, fmt.Errorf("parsing transcript: %w", err)
}
return calculateCost(usage), nil
}
// getTmuxSessionWorkDir gets the current working directory of a tmux session.
func getTmuxSessionWorkDir(session string) (string, error) {
cmd := exec.Command("tmux", "display-message", "-t", session, "-p", "#{pane_current_path}")
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
func outputCostsJSON(output CostsOutput) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
@@ -780,17 +982,31 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
return fmt.Errorf("--session flag required (or set GT_SESSION env var, or GT_RIG/GT_ROLE)")
}
t := tmux.NewTmux()
// Capture pane content
content, err := t.CapturePaneAll(session)
if err != nil {
// Session may already be gone - that's OK, we'll record with zero cost
content = ""
// Get working directory from environment or tmux session
workDir := os.Getenv("GT_CWD")
if workDir == "" {
// Try to get from tmux session
var err error
workDir, err = getTmuxSessionWorkDir(session)
if err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] could not get workdir for %s: %v\n", session, err)
}
}
}
// Extract cost
cost := extractCost(content)
// Extract cost from Claude transcript
var cost float64
if workDir != "" {
var err error
cost, err = extractCostFromWorkDir(workDir)
if err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] could not extract cost from transcript: %v\n", err)
}
cost = 0.0
}
}
// Parse session name
role, rig, worker := parseSessionName(session)