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:
@@ -2,8 +2,10 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -44,22 +46,11 @@ var (
|
|||||||
var costsCmd = &cobra.Command{
|
var costsCmd = &cobra.Command{
|
||||||
Use: "costs",
|
Use: "costs",
|
||||||
GroupID: GroupDiag,
|
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.
|
Long: `Display costs for Claude Code sessions in Gas Town.
|
||||||
|
|
||||||
⚠️ COST TRACKING IS CURRENTLY DISABLED
|
Costs are calculated from Claude Code transcript files at ~/.claude/projects/
|
||||||
|
by summing token usage from assistant messages and applying model-specific pricing.
|
||||||
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.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt costs # Live costs from running sessions
|
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-role # Breakdown by role (polecat, witness, etc.)
|
||||||
gt costs --by-rig # Breakdown by rig
|
gt costs --by-rig # Breakdown by rig
|
||||||
gt costs --json # Output as JSON
|
gt costs --json # Output as JSON
|
||||||
|
gt costs -v # Show debug output for failures
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
gt costs record # Record session cost to local log file (Stop hook)
|
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.
|
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.
|
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
|
~/.gt/costs.jsonl. This is a simple append operation that never fails
|
||||||
due to database availability.
|
due to database availability.
|
||||||
|
|
||||||
@@ -193,6 +186,56 @@ type CostsOutput struct {
|
|||||||
// costRegex matches cost patterns like "$1.23" or "$12.34"
|
// costRegex matches cost patterns like "$1.23" or "$12.34"
|
||||||
var costRegex = regexp.MustCompile(`\$(\d+\.\d{2})`)
|
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 {
|
func runCosts(cmd *cobra.Command, args []string) error {
|
||||||
// If querying ledger, use ledger functions
|
// If querying ledger, use ledger functions
|
||||||
if costsToday || costsWeek || costsByRole || costsByRig {
|
if costsToday || costsWeek || costsByRole || costsByRig {
|
||||||
@@ -204,11 +247,6 @@ func runCosts(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runLiveCosts() 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()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
// Get all tmux sessions
|
// Get all tmux sessions
|
||||||
@@ -229,14 +267,24 @@ func runLiveCosts() error {
|
|||||||
// Parse session name to get role/rig/worker
|
// Parse session name to get role/rig/worker
|
||||||
role, rig, worker := parseSessionName(session)
|
role, rig, worker := parseSessionName(session)
|
||||||
|
|
||||||
// Capture pane content
|
// Get working directory of the session
|
||||||
content, err := t.CapturePaneAll(session)
|
workDir, err := getTmuxSessionWorkDir(session)
|
||||||
if err != nil {
|
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
|
// Extract cost from Claude transcript
|
||||||
cost := extractCost(content)
|
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
|
// Check if an agent appears to be running
|
||||||
running := t.IsAgentRunning(session)
|
running := t.IsAgentRunning(session)
|
||||||
@@ -268,11 +316,6 @@ func runLiveCosts() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runCostsFromLedger() 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()
|
now := time.Now()
|
||||||
var entries []CostEntry
|
var entries []CostEntry
|
||||||
var err error
|
var err error
|
||||||
@@ -295,8 +338,15 @@ func runCostsFromLedger() error {
|
|||||||
// Also include today's wisps (not yet digested)
|
// Also include today's wisps (not yet digested)
|
||||||
todayEntries, _ := querySessionCostEntries(now)
|
todayEntries, _ := querySessionCostEntries(now)
|
||||||
entries = append(entries, todayEntries...)
|
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 {
|
} 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)
|
// (for backwards compatibility during migration)
|
||||||
entries = querySessionEvents()
|
entries = querySessionEvents()
|
||||||
}
|
}
|
||||||
@@ -637,7 +687,9 @@ func parseSessionName(session string) (role, rig, worker string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// extractCost finds the most recent cost value in pane content.
|
// 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 {
|
func extractCost(content string) float64 {
|
||||||
matches := costRegex.FindAllStringSubmatch(content, -1)
|
matches := costRegex.FindAllStringSubmatch(content, -1)
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
@@ -655,6 +707,156 @@ func extractCost(content string) float64 {
|
|||||||
return cost
|
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 {
|
func outputCostsJSON(output CostsOutput) error {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
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)")
|
return fmt.Errorf("--session flag required (or set GT_SESSION env var, or GT_RIG/GT_ROLE)")
|
||||||
}
|
}
|
||||||
|
|
||||||
t := tmux.NewTmux()
|
// Get working directory from environment or tmux session
|
||||||
|
workDir := os.Getenv("GT_CWD")
|
||||||
// Capture pane content
|
if workDir == "" {
|
||||||
content, err := t.CapturePaneAll(session)
|
// Try to get from tmux session
|
||||||
if err != nil {
|
var err error
|
||||||
// Session may already be gone - that's OK, we'll record with zero cost
|
workDir, err = getTmuxSessionWorkDir(session)
|
||||||
content = ""
|
if err != nil {
|
||||||
|
if costsVerbose {
|
||||||
|
fmt.Fprintf(os.Stderr, "[costs] could not get workdir for %s: %v\n", session, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract cost
|
// Extract cost from Claude transcript
|
||||||
cost := extractCost(content)
|
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
|
// Parse session name
|
||||||
role, rig, worker := parseSessionName(session)
|
role, rig, worker := parseSessionName(session)
|
||||||
|
|||||||
Reference in New Issue
Block a user