Files
gastown/internal/cmd/costs.go
joe 2a0420177e refactor(costs): rename wisp references to entries
- querySessionCostWisps → querySessionCostEntries
- Rename variables: wisps → costEntries, todayWisps → todayEntries
- Add comment explaining POSIX O_APPEND atomicity guarantee

Follow-up to code review feedback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:29:57 -08:00

1275 lines
36 KiB
Go

// Package cmd provides CLI commands for the gt tool.
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
costsJSON bool
costsToday bool
costsWeek bool
costsByRole bool
costsByRig bool
costsVerbose bool
// Record subcommand flags
recordSession string
recordWorkItem string
// Digest subcommand flags
digestYesterday bool
digestDate string
digestDryRun bool
// Migrate subcommand flags
migrateDryRun bool
)
var costsCmd = &cobra.Command{
Use: "costs",
GroupID: GroupDiag,
Short: "Show costs for running Claude sessions [DISABLED]",
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.
Examples:
gt costs # Live costs from running sessions
gt costs --today # Today's costs from log file (not yet digested)
gt costs --week # This week's costs from digest beads + today's log
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
gt costs --by-rig # Breakdown by rig
gt costs --json # Output as JSON
Subcommands:
gt costs record # Record session cost to local log file (Stop hook)
gt costs digest # Aggregate log entries into daily digest bead (Deacon patrol)`,
RunE: runCosts,
}
var costsRecordCmd = &cobra.Command{
Use: "record",
Short: "Record session cost to local log file (called by Stop hook)",
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
~/.gt/costs.jsonl. This is a simple append operation that never fails
due to database availability.
Session costs are aggregated daily by 'gt costs digest' into a single
permanent "Cost Report YYYY-MM-DD" bead for audit purposes.
Examples:
gt costs record --session gt-gastown-toast
gt costs record --session gt-gastown-toast --work-item gt-abc123`,
RunE: runCostsRecord,
}
var costsDigestCmd = &cobra.Command{
Use: "digest",
Short: "Aggregate session cost log entries into a daily digest bead",
Long: `Aggregate session cost log entries into a permanent daily digest.
This command is intended to be run by Deacon patrol (daily) or manually.
It reads entries from ~/.gt/costs.jsonl for a target date, creates a single
aggregate "Cost Report YYYY-MM-DD" bead, then removes the source entries.
The resulting digest bead is permanent (exported to JSONL, synced via git)
and provides an audit trail without log-in-database pollution.
Examples:
gt costs digest --yesterday # Digest yesterday's costs (default for patrol)
gt costs digest --date 2026-01-07 # Digest a specific date
gt costs digest --yesterday --dry-run # Preview without changes`,
RunE: runCostsDigest,
}
var costsMigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Migrate legacy session.ended beads to the new log-file architecture",
Long: `Migrate legacy session.ended event beads to the new cost tracking system.
This command handles the transition from the old architecture (where each
session.ended event was a permanent bead) to the new log-file-based system.
The migration:
1. Finds all open session.ended event beads (should be none if auto-close worked)
2. Closes them with reason "migrated to log-file architecture"
Legacy beads remain in the database for historical queries but won't interfere
with the new log-file-based cost tracking.
Examples:
gt costs migrate # Migrate legacy beads
gt costs migrate --dry-run # Preview what would be migrated`,
RunE: runCostsMigrate,
}
func init() {
rootCmd.AddCommand(costsCmd)
costsCmd.Flags().BoolVar(&costsJSON, "json", false, "Output as JSON")
costsCmd.Flags().BoolVar(&costsToday, "today", false, "Show today's total from session events")
costsCmd.Flags().BoolVar(&costsWeek, "week", false, "Show this week's total from session events")
costsCmd.Flags().BoolVar(&costsByRole, "by-role", false, "Show breakdown by role")
costsCmd.Flags().BoolVar(&costsByRig, "by-rig", false, "Show breakdown by rig")
costsCmd.Flags().BoolVarP(&costsVerbose, "verbose", "v", false, "Show debug output for failures")
// Add record subcommand
costsCmd.AddCommand(costsRecordCmd)
costsRecordCmd.Flags().StringVar(&recordSession, "session", "", "Tmux session name to record")
costsRecordCmd.Flags().StringVar(&recordWorkItem, "work-item", "", "Work item ID (bead) for attribution")
// Add digest subcommand
costsCmd.AddCommand(costsDigestCmd)
costsDigestCmd.Flags().BoolVar(&digestYesterday, "yesterday", false, "Digest yesterday's costs (default for patrol)")
costsDigestCmd.Flags().StringVar(&digestDate, "date", "", "Digest a specific date (YYYY-MM-DD)")
costsDigestCmd.Flags().BoolVar(&digestDryRun, "dry-run", false, "Preview what would be done without making changes")
// Add migrate subcommand
costsCmd.AddCommand(costsMigrateCmd)
costsMigrateCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Preview what would be migrated without making changes")
}
// SessionCost represents cost info for a single session.
type SessionCost struct {
Session string `json:"session"`
Role string `json:"role"`
Rig string `json:"rig,omitempty"`
Worker string `json:"worker,omitempty"`
Cost float64 `json:"cost_usd"`
Running bool `json:"running"`
}
// CostEntry is a ledger entry for historical cost tracking.
type CostEntry struct {
SessionID string `json:"session_id"`
Role string `json:"role"`
Rig string `json:"rig,omitempty"`
Worker string `json:"worker,omitempty"`
CostUSD float64 `json:"cost_usd"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
WorkItem string `json:"work_item,omitempty"`
}
// CostsOutput is the JSON output structure.
type CostsOutput struct {
Sessions []SessionCost `json:"sessions,omitempty"`
Total float64 `json:"total_usd"`
ByRole map[string]float64 `json:"by_role,omitempty"`
ByRig map[string]float64 `json:"by_rig,omitempty"`
Period string `json:"period,omitempty"`
}
// costRegex matches cost patterns like "$1.23" or "$12.34"
var costRegex = regexp.MustCompile(`\$(\d+\.\d{2})`)
func runCosts(cmd *cobra.Command, args []string) error {
// If querying ledger, use ledger functions
if costsToday || costsWeek || costsByRole || costsByRig {
return runCostsFromLedger()
}
// Default: show live costs from running sessions
return runLiveCosts()
}
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
sessions, err := t.ListSessions()
if err != nil {
return fmt.Errorf("listing sessions: %w", err)
}
var costs []SessionCost
var total float64
for _, session := range sessions {
// Only process Gas Town sessions (start with "gt-")
if !strings.HasPrefix(session, constants.SessionPrefix) {
continue
}
// Parse session name to get role/rig/worker
role, rig, worker := parseSessionName(session)
// Capture pane content
content, err := t.CapturePaneAll(session)
if err != nil {
continue // Skip sessions we can't capture
}
// Extract cost from content
cost := extractCost(content)
// Check if an agent appears to be running
running := t.IsAgentRunning(session)
costs = append(costs, SessionCost{
Session: session,
Role: role,
Rig: rig,
Worker: worker,
Cost: cost,
Running: running,
})
total += cost
}
// Sort by session name
sort.Slice(costs, func(i, j int) bool {
return costs[i].Session < costs[j].Session
})
if costsJSON {
return outputCostsJSON(CostsOutput{
Sessions: costs,
Total: total,
})
}
return outputCostsHuman(costs, total)
}
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
if costsToday {
// For today: query ephemeral wisps (not yet digested)
// This gives real-time view of today's costs
entries, err = querySessionCostEntries(now)
if err != nil {
return fmt.Errorf("querying session cost wisps: %w", err)
}
} else if costsWeek {
// For week: query digest beads (costs.digest events)
// These are the aggregated daily reports
entries, err = queryDigestBeads(7)
if err != nil {
return fmt.Errorf("querying digest beads: %w", err)
}
// Also include today's wisps (not yet digested)
todayEntries, _ := querySessionCostEntries(now)
entries = append(entries, todayEntries...)
} else {
// No time filter: query both digests and legacy session.ended events
// (for backwards compatibility during migration)
entries = querySessionEvents()
}
if len(entries) == 0 {
fmt.Println(style.Dim.Render("No cost data found. Costs are recorded when sessions end."))
return nil
}
// Calculate totals
var total float64
byRole := make(map[string]float64)
byRig := make(map[string]float64)
for _, entry := range entries {
total += entry.CostUSD
byRole[entry.Role] += entry.CostUSD
if entry.Rig != "" {
byRig[entry.Rig] += entry.CostUSD
}
}
// Build output
output := CostsOutput{
Total: total,
}
if costsByRole {
output.ByRole = byRole
}
if costsByRig {
output.ByRig = byRig
}
// Set period label
if costsToday {
output.Period = "today"
} else if costsWeek {
output.Period = "this week"
}
if costsJSON {
return outputCostsJSON(output)
}
return outputLedgerHuman(output, entries)
}
// SessionEvent represents a session.ended event from beads.
type SessionEvent struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
EventKind string `json:"event_kind"`
Actor string `json:"actor"`
Target string `json:"target"`
Payload string `json:"payload"`
}
// SessionPayload represents the JSON payload of a session event.
type SessionPayload struct {
CostUSD float64 `json:"cost_usd"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Rig string `json:"rig"`
Worker string `json:"worker"`
EndedAt string `json:"ended_at"`
}
// EventListItem represents an event from bd list (minimal fields).
type EventListItem struct {
ID string `json:"id"`
}
// querySessionEvents queries beads for session.ended events and converts them to CostEntry.
// It queries both town-level beads and all rig-level beads to find all session events.
// Errors from individual locations are logged (if verbose) but don't fail the query.
func querySessionEvents() []CostEntry {
// Discover town root for cwd-based bd discovery
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
// Not in a Gas Town workspace - return empty list
return nil
}
// Collect all beads locations to query
beadsLocations := []string{townRoot}
// Load rigs to find all rig beads locations
rigsConfigPath := filepath.Join(townRoot, constants.DirMayor, constants.FileRigsJSON)
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err == nil && rigsConfig != nil {
for rigName := range rigsConfig.Rigs {
rigPath := filepath.Join(townRoot, rigName)
// Verify rig has a beads database
rigBeadsPath := filepath.Join(rigPath, constants.DirBeads)
if _, statErr := os.Stat(rigBeadsPath); statErr == nil {
beadsLocations = append(beadsLocations, rigPath)
}
}
}
// Query each beads location and merge results
var allEntries []CostEntry
seenIDs := make(map[string]bool)
for _, location := range beadsLocations {
entries, err := querySessionEventsFromLocation(location)
if err != nil {
// Log but continue with other locations
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] query from %s failed: %v\n", location, err)
}
continue
}
// Deduplicate by event ID (use SessionID as key)
for _, entry := range entries {
key := entry.SessionID + entry.EndedAt.String()
if !seenIDs[key] {
seenIDs[key] = true
allEntries = append(allEntries, entry)
}
}
}
return allEntries
}
// querySessionEventsFromLocation queries a single beads location for session.ended events.
func querySessionEventsFromLocation(location string) ([]CostEntry, error) {
// Step 1: Get list of event IDs
listArgs := []string{
"list",
"--type=event",
"--all",
"--limit=0",
"--json",
}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = location
listOutput, err := listCmd.Output()
if err != nil {
// If bd fails (e.g., no beads database), return empty list
return nil, nil
}
var listItems []EventListItem
if err := json.Unmarshal(listOutput, &listItems); err != nil {
return nil, fmt.Errorf("parsing event list: %w", err)
}
if len(listItems) == 0 {
return nil, nil
}
// Step 2: Get full details for all events using bd show
// (bd list doesn't include event_kind, actor, payload)
showArgs := []string{"show", "--json"}
for _, item := range listItems {
showArgs = append(showArgs, item.ID)
}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = location
showOutput, err := showCmd.Output()
if err != nil {
return nil, fmt.Errorf("showing events: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return nil, fmt.Errorf("parsing event details: %w", err)
}
var entries []CostEntry
for _, event := range events {
// Filter for session.ended events only
if event.EventKind != "session.ended" {
continue
}
// Parse payload
var payload SessionPayload
if event.Payload != "" {
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
continue // Skip malformed payloads
}
}
// Parse ended_at from payload, fall back to created_at
endedAt := event.CreatedAt
if payload.EndedAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
endedAt = parsed
}
}
entries = append(entries, CostEntry{
SessionID: payload.SessionID,
Role: payload.Role,
Rig: payload.Rig,
Worker: payload.Worker,
CostUSD: payload.CostUSD,
EndedAt: endedAt,
WorkItem: event.Target,
})
}
return entries, nil
}
// queryDigestBeads queries costs.digest events from the past N days and extracts session entries.
func queryDigestBeads(days int) ([]CostEntry, error) {
// Get list of event IDs
listArgs := []string{
"list",
"--type=event",
"--all",
"--limit=0",
"--json",
}
listCmd := exec.Command("bd", listArgs...)
listOutput, err := listCmd.Output()
if err != nil {
return nil, nil
}
var listItems []EventListItem
if err := json.Unmarshal(listOutput, &listItems); err != nil {
return nil, fmt.Errorf("parsing event list: %w", err)
}
if len(listItems) == 0 {
return nil, nil
}
// Get full details for all events
showArgs := []string{"show", "--json"}
for _, item := range listItems {
showArgs = append(showArgs, item.ID)
}
showCmd := exec.Command("bd", showArgs...)
showOutput, err := showCmd.Output()
if err != nil {
return nil, fmt.Errorf("showing events: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return nil, fmt.Errorf("parsing event details: %w", err)
}
// Calculate date range
now := time.Now()
cutoff := now.AddDate(0, 0, -days)
var entries []CostEntry
for _, event := range events {
// Filter for costs.digest events only
if event.EventKind != "costs.digest" {
continue
}
// Parse the digest payload
var digest CostDigest
if event.Payload != "" {
if err := json.Unmarshal([]byte(event.Payload), &digest); err != nil {
continue
}
}
// Check date is within range
digestDate, err := time.Parse("2006-01-02", digest.Date)
if err != nil {
continue
}
if digestDate.Before(cutoff) {
continue
}
// Extract individual session entries from the digest
entries = append(entries, digest.Sessions...)
}
return entries, nil
}
// parseSessionName extracts role, rig, and worker from a session name.
// Session names follow the pattern: gt-<rig>-<worker> or gt-<global-agent>
// Examples:
// - gt-mayor -> role=mayor, rig="", worker="mayor"
// - gt-deacon -> role=deacon, rig="", worker="deacon"
// - gt-gastown-toast -> role=polecat, rig=gastown, worker=toast
// - gt-gastown-witness -> role=witness, rig=gastown, worker=""
// - gt-gastown-refinery -> role=refinery, rig=gastown, worker=""
// - gt-gastown-crew-joe -> role=crew, rig=gastown, worker=joe
func parseSessionName(session string) (role, rig, worker string) {
// Remove gt- prefix
name := strings.TrimPrefix(session, constants.SessionPrefix)
// Check for global agents
switch name {
case "mayor":
return constants.RoleMayor, "", "mayor"
case "deacon":
return constants.RoleDeacon, "", "deacon"
}
// Parse rig-based session: rig-worker or rig-crew-name
parts := strings.SplitN(name, "-", 3)
if len(parts) < 2 {
return "unknown", "", name
}
rig = parts[0]
worker = parts[1]
// Check for crew pattern: rig-crew-name
if worker == "crew" && len(parts) >= 3 {
return constants.RoleCrew, rig, parts[2]
}
// Check for special workers
switch worker {
case "witness":
return constants.RoleWitness, rig, ""
case "refinery":
return constants.RoleRefinery, rig, ""
}
// Default to polecat
return constants.RolePolecat, rig, worker
}
// extractCost finds the most recent cost value in pane content.
// Claude Code displays cost in the format "$X.XX" in the status area.
func extractCost(content string) float64 {
matches := costRegex.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
return 0.0
}
// Get the last (most recent) match
lastMatch := matches[len(matches)-1]
if len(lastMatch) < 2 {
return 0.0
}
var cost float64
_, _ = fmt.Sscanf(lastMatch[1], "%f", &cost)
return cost
}
func outputCostsJSON(output CostsOutput) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(output)
}
func outputCostsHuman(costs []SessionCost, total float64) error {
if len(costs) == 0 {
fmt.Println(style.Dim.Render("No Gas Town sessions found"))
return nil
}
fmt.Printf("\n%s Live Session Costs\n\n", style.Bold.Render("💰"))
// Print table header
fmt.Printf("%-25s %-10s %-15s %10s %8s\n",
"Session", "Role", "Rig/Worker", "Cost", "Status")
fmt.Println(strings.Repeat("─", 75))
// Print each session
for _, c := range costs {
statusIcon := style.Success.Render("●")
if !c.Running {
statusIcon = style.Dim.Render("○")
}
rigWorker := c.Rig
if c.Worker != "" && c.Worker != c.Rig {
if rigWorker != "" {
rigWorker += "/" + c.Worker
} else {
rigWorker = c.Worker
}
}
fmt.Printf("%-25s %-10s %-15s %10s %8s\n",
c.Session,
c.Role,
rigWorker,
fmt.Sprintf("$%.2f", c.Cost),
statusIcon)
}
// Print total
fmt.Println(strings.Repeat("─", 75))
fmt.Printf("%s %s\n", style.Bold.Render("Total:"), fmt.Sprintf("$%.2f", total))
return nil
}
func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
periodStr := ""
if output.Period != "" {
periodStr = fmt.Sprintf(" (%s)", output.Period)
}
fmt.Printf("\n%s Cost Summary%s\n\n", style.Bold.Render("📊"), periodStr)
// Total
fmt.Printf("%s $%.2f\n", style.Bold.Render("Total:"), output.Total)
// By role breakdown
if output.ByRole != nil && len(output.ByRole) > 0 {
fmt.Printf("\n%s\n", style.Bold.Render("By Role:"))
for role, cost := range output.ByRole {
icon := constants.RoleEmoji(role)
fmt.Printf(" %s %-12s $%.2f\n", icon, role, cost)
}
}
// By rig breakdown
if output.ByRig != nil && len(output.ByRig) > 0 {
fmt.Printf("\n%s\n", style.Bold.Render("By Rig:"))
for rig, cost := range output.ByRig {
fmt.Printf(" %-15s $%.2f\n", rig, cost)
}
}
// Session count
fmt.Printf("\n%s %d sessions\n", style.Dim.Render("Entries:"), len(entries))
return nil
}
// CostLogEntry represents a single entry in the costs.jsonl log file.
type CostLogEntry struct {
SessionID string `json:"session_id"`
Role string `json:"role"`
Rig string `json:"rig,omitempty"`
Worker string `json:"worker,omitempty"`
CostUSD float64 `json:"cost_usd"`
EndedAt time.Time `json:"ended_at"`
WorkItem string `json:"work_item,omitempty"`
}
// getCostsLogPath returns the path to the costs log file (~/.gt/costs.jsonl).
func getCostsLogPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "/tmp/gt-costs.jsonl" // Fallback
}
return filepath.Join(home, ".gt", "costs.jsonl")
}
// runCostsRecord captures the final cost from a session and appends it to a local log file.
// This is called by the Claude Code Stop hook. It's designed to never fail due to
// database availability - it's a simple file append operation.
func runCostsRecord(cmd *cobra.Command, args []string) error {
// Get session from flag or try to detect from environment
session := recordSession
if session == "" {
session = os.Getenv("GT_SESSION")
}
if session == "" {
// Derive session name from GT_* environment variables
session = deriveSessionName()
}
if session == "" {
// Try to detect current tmux session (works when running inside tmux)
session = detectCurrentTmuxSession()
}
if session == "" {
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 = ""
}
// Extract cost
cost := extractCost(content)
// Parse session name
role, rig, worker := parseSessionName(session)
// Build log entry
entry := CostLogEntry{
SessionID: session,
Role: role,
Rig: rig,
Worker: worker,
CostUSD: cost,
EndedAt: time.Now(),
WorkItem: recordWorkItem,
}
// Marshal to JSON
entryJSON, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshaling cost entry: %w", err)
}
// Append to log file
logPath := getCostsLogPath()
// Ensure directory exists
logDir := filepath.Dir(logPath)
if err := os.MkdirAll(logDir, 0755); err != nil {
return fmt.Errorf("creating log directory: %w", err)
}
// Open file for append (create if doesn't exist).
// O_APPEND writes are atomic on POSIX for writes < PIPE_BUF (~4KB).
// A JSON log entry is ~200 bytes, so concurrent appends are safe.
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("opening costs log: %w", err)
}
defer f.Close()
// Write entry with newline
if _, err := f.Write(append(entryJSON, '\n')); err != nil {
return fmt.Errorf("writing to costs log: %w", err)
}
// Output confirmation (silent if cost is zero and no work item)
if cost > 0 || recordWorkItem != "" {
fmt.Printf("%s Recorded $%.2f for %s", style.Success.Render("✓"), cost, session)
if recordWorkItem != "" {
fmt.Printf(" (work: %s)", recordWorkItem)
}
fmt.Println()
}
return nil
}
// deriveSessionName derives the tmux session name from GT_* environment variables.
// Session naming patterns:
// - Polecats: gt-{rig}-{polecat} (e.g., gt-gastown-toast)
// - Crew: gt-{rig}-crew-{crew} (e.g., gt-gastown-crew-max)
// - Witness/Refinery: gt-{rig}-{role} (e.g., gt-gastown-witness)
// - Mayor/Deacon: gt-{town}-{role} (e.g., gt-ai-mayor)
func deriveSessionName() string {
role := os.Getenv("GT_ROLE")
rig := os.Getenv("GT_RIG")
polecat := os.Getenv("GT_POLECAT")
crew := os.Getenv("GT_CREW")
town := os.Getenv("GT_TOWN")
// Polecat: gt-{rig}-{polecat}
if polecat != "" && rig != "" {
return fmt.Sprintf("gt-%s-%s", rig, polecat)
}
// Crew: gt-{rig}-crew-{crew}
if crew != "" && rig != "" {
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
}
// Town-level roles (mayor, deacon): gt-{town}-{role} or gt-{role}
if role == "mayor" || role == "deacon" {
if town != "" {
return fmt.Sprintf("gt-%s-%s", town, role)
}
// No town set - use simple gt-{role} pattern
return fmt.Sprintf("gt-%s", role)
}
// Rig-based roles (witness, refinery): gt-{rig}-{role}
if role != "" && rig != "" {
return fmt.Sprintf("gt-%s-%s", rig, role)
}
return ""
}
// detectCurrentTmuxSession returns the current tmux session name if running inside tmux.
// Uses `tmux display-message -p '#S'` which prints the session name.
// Note: We don't check TMUX env var because it may not be inherited when Claude Code
// runs bash commands, even though we are inside a tmux session.
func detectCurrentTmuxSession() string {
cmd := exec.Command("tmux", "display-message", "-p", "#S")
output, err := cmd.Output()
if err != nil {
return ""
}
session := strings.TrimSpace(string(output))
// Only return if it looks like a Gas Town session
// Accept both gt- (rig sessions) and hq- (town-level sessions like hq-mayor)
if strings.HasPrefix(session, constants.SessionPrefix) || strings.HasPrefix(session, constants.HQSessionPrefix) {
return session
}
return ""
}
// CostDigest represents the aggregated daily cost report.
type CostDigest struct {
Date string `json:"date"`
TotalUSD float64 `json:"total_usd"`
SessionCount int `json:"session_count"`
Sessions []CostEntry `json:"sessions"`
ByRole map[string]float64 `json:"by_role"`
ByRig map[string]float64 `json:"by_rig,omitempty"`
}
// runCostsDigest aggregates session cost entries into a daily digest bead.
func runCostsDigest(cmd *cobra.Command, args []string) error {
// Determine target date
var targetDate time.Time
if digestDate != "" {
parsed, err := time.Parse("2006-01-02", digestDate)
if err != nil {
return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
}
targetDate = parsed
} else if digestYesterday {
targetDate = time.Now().AddDate(0, 0, -1)
} else {
return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD")
}
dateStr := targetDate.Format("2006-01-02")
// Query session cost entries for target date
costEntries, err := querySessionCostEntries(targetDate)
if err != nil {
return fmt.Errorf("querying session cost entries: %w", err)
}
if len(costEntries) == 0 {
fmt.Printf("%s No session cost entries found for %s\n", style.Dim.Render("○"), dateStr)
return nil
}
// Build digest
digest := CostDigest{
Date: dateStr,
Sessions: costEntries,
ByRole: make(map[string]float64),
ByRig: make(map[string]float64),
}
for _, e := range costEntries {
digest.TotalUSD += e.CostUSD
digest.SessionCount++
digest.ByRole[e.Role] += e.CostUSD
if e.Rig != "" {
digest.ByRig[e.Rig] += e.CostUSD
}
}
if digestDryRun {
fmt.Printf("%s [DRY RUN] Would create Cost Report %s:\n", style.Bold.Render("📊"), dateStr)
fmt.Printf(" Total: $%.2f\n", digest.TotalUSD)
fmt.Printf(" Sessions: %d\n", digest.SessionCount)
fmt.Printf(" By Role:\n")
for role, cost := range digest.ByRole {
fmt.Printf(" %s: $%.2f\n", role, cost)
}
if len(digest.ByRig) > 0 {
fmt.Printf(" By Rig:\n")
for rig, cost := range digest.ByRig {
fmt.Printf(" %s: $%.2f\n", rig, cost)
}
}
return nil
}
// Create permanent digest bead
digestID, err := createCostDigestBead(digest)
if err != nil {
return fmt.Errorf("creating digest bead: %w", err)
}
// Delete source entries from log file
deletedCount, deleteErr := deleteSessionCostEntries(targetDate)
if deleteErr != nil {
fmt.Fprintf(os.Stderr, "warning: failed to delete some source entries: %v\n", deleteErr)
}
fmt.Printf("%s Created Cost Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
fmt.Printf(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
if deletedCount > 0 {
fmt.Printf(" Removed %d entries from costs log\n", deletedCount)
}
return nil
}
// querySessionCostEntries reads session cost entries from the local log file for a target date.
func querySessionCostEntries(targetDate time.Time) ([]CostEntry, error) {
logPath := getCostsLogPath()
// Read log file
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No log file yet
}
return nil, fmt.Errorf("reading costs log: %w", err)
}
targetDay := targetDate.Format("2006-01-02")
var entries []CostEntry
// Parse each line as a CostLogEntry
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var logEntry CostLogEntry
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] failed to parse log entry: %v\n", err)
}
continue
}
// Filter by target date
if logEntry.EndedAt.Format("2006-01-02") != targetDay {
continue
}
entries = append(entries, CostEntry{
SessionID: logEntry.SessionID,
Role: logEntry.Role,
Rig: logEntry.Rig,
Worker: logEntry.Worker,
CostUSD: logEntry.CostUSD,
EndedAt: logEntry.EndedAt,
WorkItem: logEntry.WorkItem,
})
}
return entries, nil
}
// createCostDigestBead creates a permanent bead for the daily cost digest.
func createCostDigestBead(digest CostDigest) (string, error) {
// Build description with aggregate data
var desc strings.Builder
desc.WriteString(fmt.Sprintf("Daily cost aggregate for %s.\n\n", digest.Date))
desc.WriteString(fmt.Sprintf("**Total:** $%.2f from %d sessions\n\n", digest.TotalUSD, digest.SessionCount))
if len(digest.ByRole) > 0 {
desc.WriteString("## By Role\n")
roles := make([]string, 0, len(digest.ByRole))
for role := range digest.ByRole {
roles = append(roles, role)
}
sort.Strings(roles)
for _, role := range roles {
icon := constants.RoleEmoji(role)
desc.WriteString(fmt.Sprintf("- %s %s: $%.2f\n", icon, role, digest.ByRole[role]))
}
desc.WriteString("\n")
}
if len(digest.ByRig) > 0 {
desc.WriteString("## By Rig\n")
rigs := make([]string, 0, len(digest.ByRig))
for rig := range digest.ByRig {
rigs = append(rigs, rig)
}
sort.Strings(rigs)
for _, rig := range rigs {
desc.WriteString(fmt.Sprintf("- %s: $%.2f\n", rig, digest.ByRig[rig]))
}
desc.WriteString("\n")
}
// Build payload JSON with full session details
payloadJSON, err := json.Marshal(digest)
if err != nil {
return "", fmt.Errorf("marshaling digest payload: %w", err)
}
// Create the digest bead (NOT ephemeral - this is permanent)
title := fmt.Sprintf("Cost Report %s", digest.Date)
bdArgs := []string{
"create",
"--type=event",
"--title=" + title,
"--event-category=costs.digest",
"--event-payload=" + string(payloadJSON),
"--description=" + desc.String(),
"--silent",
}
bdCmd := exec.Command("bd", bdArgs...)
output, err := bdCmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("creating digest bead: %w\nOutput: %s", err, string(output))
}
digestID := strings.TrimSpace(string(output))
// Auto-close the digest (it's an audit record, not work)
closeCmd := exec.Command("bd", "close", digestID, "--reason=daily cost digest")
_ = closeCmd.Run() // Best effort
return digestID, nil
}
// deleteSessionCostEntries removes entries for a target date from the costs log file.
// It rewrites the file without the entries for that date.
func deleteSessionCostEntries(targetDate time.Time) (int, error) {
logPath := getCostsLogPath()
// Read log file
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return 0, nil // No log file
}
return 0, fmt.Errorf("reading costs log: %w", err)
}
targetDay := targetDate.Format("2006-01-02")
var keepLines []string
deletedCount := 0
// Filter out entries for target date
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var logEntry CostLogEntry
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
// Keep unparseable lines (shouldn't happen but be safe)
keepLines = append(keepLines, line)
continue
}
// Remove entries from target date
if logEntry.EndedAt.Format("2006-01-02") == targetDay {
deletedCount++
continue
}
keepLines = append(keepLines, line)
}
if deletedCount == 0 {
return 0, nil
}
// Rewrite file without deleted entries
newContent := strings.Join(keepLines, "\n")
if len(keepLines) > 0 {
newContent += "\n"
}
if err := os.WriteFile(logPath, []byte(newContent), 0644); err != nil {
return 0, fmt.Errorf("rewriting costs log: %w", err)
}
return deletedCount, nil
}
// runCostsMigrate migrates legacy session.ended beads to the new architecture.
func runCostsMigrate(cmd *cobra.Command, args []string) error {
// Query all session.ended events (both open and closed)
listArgs := []string{
"list",
"--type=event",
"--all",
"--limit=0",
"--json",
}
listCmd := exec.Command("bd", listArgs...)
listOutput, err := listCmd.Output()
if err != nil {
fmt.Println(style.Dim.Render("No events found or bd command failed"))
return nil
}
var listItems []EventListItem
if err := json.Unmarshal(listOutput, &listItems); err != nil {
return fmt.Errorf("parsing event list: %w", err)
}
if len(listItems) == 0 {
fmt.Println(style.Dim.Render("No events found"))
return nil
}
// Get full details for all events
showArgs := []string{"show", "--json"}
for _, item := range listItems {
showArgs = append(showArgs, item.ID)
}
showCmd := exec.Command("bd", showArgs...)
showOutput, err := showCmd.Output()
if err != nil {
return fmt.Errorf("showing events: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return fmt.Errorf("parsing event details: %w", err)
}
// Find open session.ended events
var openEvents []SessionEvent
var closedCount int
for _, event := range events {
if event.EventKind != "session.ended" {
continue
}
if event.Status == "closed" {
closedCount++
continue
}
openEvents = append(openEvents, event)
}
fmt.Printf("%s Legacy session.ended beads:\n", style.Bold.Render("📊"))
fmt.Printf(" Closed: %d (no action needed)\n", closedCount)
fmt.Printf(" Open: %d (will be closed)\n", len(openEvents))
if len(openEvents) == 0 {
fmt.Println(style.Success.Render("\n✓ No migration needed - all session.ended events are already closed"))
return nil
}
if migrateDryRun {
fmt.Printf("\n%s Would close %d open session.ended events\n", style.Bold.Render("[DRY RUN]"), len(openEvents))
for _, event := range openEvents {
fmt.Printf(" - %s: %s\n", event.ID, event.Title)
}
return nil
}
// Close all open session.ended events
closedMigrated := 0
for _, event := range openEvents {
closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to log-file architecture")
if err := closeCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
continue
}
closedMigrated++
}
fmt.Printf("\n%s Migrated %d session.ended events (closed)\n", style.Success.Render("✓"), closedMigrated)
fmt.Println(style.Dim.Render("Legacy beads preserved for historical queries."))
fmt.Println(style.Dim.Render("New session costs will use ~/.gt/costs.jsonl + daily digests."))
return nil
}