Cost tracking infrastructure works but has no data source: - Claude Code displays costs in TUI status bar, not scrollback - tmux capture-pane can't see TUI chrome - All sessions show $0.00 Changes: - Mark gt costs command as [DISABLED] with deprecation warnings - Mark costs-digest patrol step as [DISABLED] with skip instructions - Document requirement for Claude Code to expose CLAUDE_SESSION_COST Infrastructure preserved for re-enabling when Claude Code adds support. Ref: GH#24, gt-7awfjq Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1416 lines
40 KiB
Go
1416 lines
40 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 wisps (not yet digested)
|
|
gt costs --week # This week's costs from digest beads + today's wisps
|
|
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 as ephemeral wisp (Stop hook)
|
|
gt costs digest # Aggregate wisps into daily digest bead (Deacon patrol)`,
|
|
RunE: runCosts,
|
|
}
|
|
|
|
var costsRecordCmd = &cobra.Command{
|
|
Use: "record",
|
|
Short: "Record session cost as an ephemeral wisp (called by Stop hook)",
|
|
Long: `Record the final cost of a session as an ephemeral wisp.
|
|
|
|
This command is intended to be called from a Claude Code Stop hook.
|
|
It captures the final cost from the tmux session and creates an ephemeral
|
|
event that is NOT exported to JSONL (avoiding log-in-database pollution).
|
|
|
|
Session cost wisps 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 wisps into a daily digest bead",
|
|
Long: `Aggregate ephemeral session cost wisps into a permanent daily digest.
|
|
|
|
This command is intended to be run by Deacon patrol (daily) or manually.
|
|
It queries session.ended wisps for a target date, creates a single aggregate
|
|
"Cost Report YYYY-MM-DD" bead, then deletes the source wisps.
|
|
|
|
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 wisp 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 wisp-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 wisp architecture"
|
|
|
|
Legacy beads remain in the database for historical queries but won't interfere
|
|
with the new wisp-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 = querySessionCostWisps(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)
|
|
todayWisps, _ := querySessionCostWisps(now)
|
|
entries = append(entries, todayWisps...)
|
|
} 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
|
|
}
|
|
|
|
// runCostsRecord captures the final cost from a session and records it as a bead event.
|
|
// This is called by the Claude Code Stop hook.
|
|
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 agent path for actor field
|
|
agentPath := buildAgentPath(role, rig, worker)
|
|
|
|
// Build event title
|
|
title := fmt.Sprintf("Session ended: %s", session)
|
|
if recordWorkItem != "" {
|
|
title = fmt.Sprintf("Session: %s completed %s", session, recordWorkItem)
|
|
}
|
|
|
|
// Build payload JSON
|
|
payload := map[string]interface{}{
|
|
"cost_usd": cost,
|
|
"session_id": session,
|
|
"role": role,
|
|
"ended_at": time.Now().Format(time.RFC3339),
|
|
}
|
|
if rig != "" {
|
|
payload["rig"] = rig
|
|
}
|
|
if worker != "" {
|
|
payload["worker"] = worker
|
|
}
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling payload: %w", err)
|
|
}
|
|
|
|
// Build bd create command for ephemeral wisp
|
|
// Using --ephemeral creates a wisp that:
|
|
// - Is stored locally only (not exported to JSONL)
|
|
// - Won't pollute git history with O(sessions/day) events
|
|
// - Will be aggregated into daily digests by 'gt costs digest'
|
|
bdArgs := []string{
|
|
"create",
|
|
"--ephemeral",
|
|
"--type=event",
|
|
"--title=" + title,
|
|
"--event-category=session.ended",
|
|
"--event-actor=" + agentPath,
|
|
"--event-payload=" + string(payloadJSON),
|
|
"--silent",
|
|
}
|
|
|
|
// Add work item as event target if specified
|
|
if recordWorkItem != "" {
|
|
bdArgs = append(bdArgs, "--event-target="+recordWorkItem)
|
|
}
|
|
|
|
// NOTE: We intentionally don't use --rig flag here because it causes
|
|
// event fields (event_kind, actor, payload) to not be stored properly.
|
|
// The bd command will auto-detect the correct rig from cwd.
|
|
|
|
// Find town root so bd can find the .beads database.
|
|
// The stop hook may run from a role subdirectory (e.g., mayor/) that
|
|
// doesn't have its own .beads, so we need to run bd from town root.
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
if townRoot == "" {
|
|
return fmt.Errorf("not in a Gas Town workspace")
|
|
}
|
|
|
|
// Execute bd create from town root
|
|
bdCmd := exec.Command("bd", bdArgs...)
|
|
bdCmd.Dir = townRoot
|
|
output, err := bdCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
|
|
}
|
|
|
|
wispID := strings.TrimSpace(string(output))
|
|
|
|
// Auto-close session cost wisps immediately after creation.
|
|
// These are informational records that don't need to stay open.
|
|
// The wisp data is preserved and queryable until digested.
|
|
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
|
|
closeCmd.Dir = townRoot
|
|
if closeErr := closeCmd.Run(); closeErr != nil {
|
|
// Non-fatal: wisp was created, just couldn't auto-close
|
|
fmt.Fprintf(os.Stderr, "warning: could not auto-close session cost wisp %s: %v\n", wispID, closeErr)
|
|
}
|
|
|
|
// Output confirmation (silent if cost is zero and no work item)
|
|
if cost > 0 || recordWorkItem != "" {
|
|
fmt.Printf("%s Recorded $%.2f for %s (wisp: %s)", style.Success.Render("✓"), cost, session, wispID)
|
|
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 ""
|
|
}
|
|
|
|
// buildAgentPath builds the agent path from role, rig, and worker.
|
|
// Examples: "mayor", "gastown/witness", "gastown/polecats/toast"
|
|
func buildAgentPath(role, rig, worker string) string {
|
|
switch role {
|
|
case constants.RoleMayor, constants.RoleDeacon:
|
|
return role
|
|
case constants.RoleWitness, constants.RoleRefinery:
|
|
if rig != "" {
|
|
return rig + "/" + role
|
|
}
|
|
return role
|
|
case constants.RolePolecat:
|
|
if rig != "" && worker != "" {
|
|
return rig + "/polecats/" + worker
|
|
}
|
|
if rig != "" {
|
|
return rig + "/polecat"
|
|
}
|
|
return "polecat/" + worker
|
|
case constants.RoleCrew:
|
|
if rig != "" && worker != "" {
|
|
return rig + "/crew/" + worker
|
|
}
|
|
if rig != "" {
|
|
return rig + "/crew"
|
|
}
|
|
return "crew/" + worker
|
|
default:
|
|
if rig != "" && worker != "" {
|
|
return rig + "/" + worker
|
|
}
|
|
if rig != "" {
|
|
return rig
|
|
}
|
|
return worker
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// WispListOutput represents the JSON output from bd mol wisp list.
|
|
type WispListOutput struct {
|
|
Wisps []WispItem `json:"wisps"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// WispItem represents a single wisp from bd mol wisp list.
|
|
type WispItem struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// runCostsDigest aggregates session cost wisps 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 ephemeral session.ended wisps for target date
|
|
wisps, err := querySessionCostWisps(targetDate)
|
|
if err != nil {
|
|
return fmt.Errorf("querying session cost wisps: %w", err)
|
|
}
|
|
|
|
if len(wisps) == 0 {
|
|
fmt.Printf("%s No session cost wisps found for %s\n", style.Dim.Render("○"), dateStr)
|
|
return nil
|
|
}
|
|
|
|
// Build digest
|
|
digest := CostDigest{
|
|
Date: dateStr,
|
|
Sessions: wisps,
|
|
ByRole: make(map[string]float64),
|
|
ByRig: make(map[string]float64),
|
|
}
|
|
|
|
for _, w := range wisps {
|
|
digest.TotalUSD += w.CostUSD
|
|
digest.SessionCount++
|
|
digest.ByRole[w.Role] += w.CostUSD
|
|
if w.Rig != "" {
|
|
digest.ByRig[w.Rig] += w.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 wisps (they're ephemeral, use bd mol burn)
|
|
deletedCount, deleteErr := deleteSessionCostWisps(targetDate)
|
|
if deleteErr != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: failed to delete some source wisps: %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(" Deleted %d source wisps\n", deletedCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// querySessionCostWisps queries ephemeral session.ended events for a target date.
|
|
func querySessionCostWisps(targetDate time.Time) ([]CostEntry, error) {
|
|
// List all wisps including closed ones
|
|
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
|
listOutput, err := listCmd.Output()
|
|
if err != nil {
|
|
// No wisps database or command failed
|
|
if costsVerbose {
|
|
fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
var wispList WispListOutput
|
|
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
|
return nil, fmt.Errorf("parsing wisp list: %w", err)
|
|
}
|
|
|
|
if wispList.Count == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Batch all wisp IDs into a single bd show call to avoid N+1 queries
|
|
showArgs := []string{"show", "--json"}
|
|
for _, wisp := range wispList.Wisps {
|
|
showArgs = append(showArgs, wisp.ID)
|
|
}
|
|
|
|
showCmd := exec.Command("bd", showArgs...)
|
|
showOutput, err := showCmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("showing wisps: %w", err)
|
|
}
|
|
|
|
var events []SessionEvent
|
|
if err := json.Unmarshal(showOutput, &events); err != nil {
|
|
return nil, fmt.Errorf("parsing wisp details: %w", err)
|
|
}
|
|
|
|
var sessionCostWisps []CostEntry
|
|
targetDay := targetDate.Format("2006-01-02")
|
|
|
|
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 {
|
|
if costsVerbose {
|
|
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for event %s: %v\n", event.ID, err)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Parse ended_at and filter by target date
|
|
endedAt := event.CreatedAt
|
|
if payload.EndedAt != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
|
|
endedAt = parsed
|
|
}
|
|
}
|
|
|
|
// Check if this event is from the target date
|
|
if endedAt.Format("2006-01-02") != targetDay {
|
|
continue
|
|
}
|
|
|
|
sessionCostWisps = append(sessionCostWisps, CostEntry{
|
|
SessionID: payload.SessionID,
|
|
Role: payload.Role,
|
|
Rig: payload.Rig,
|
|
Worker: payload.Worker,
|
|
CostUSD: payload.CostUSD,
|
|
EndedAt: endedAt,
|
|
WorkItem: event.Target,
|
|
})
|
|
}
|
|
|
|
return sessionCostWisps, 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
|
|
}
|
|
|
|
// deleteSessionCostWisps deletes ephemeral session.ended wisps for a target date.
|
|
func deleteSessionCostWisps(targetDate time.Time) (int, error) {
|
|
// List all wisps
|
|
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
|
listOutput, err := listCmd.Output()
|
|
if err != nil {
|
|
if costsVerbose {
|
|
fmt.Fprintf(os.Stderr, "[costs] wisp list failed in deletion: %v\n", err)
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
var wispList WispListOutput
|
|
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
|
return 0, fmt.Errorf("parsing wisp list: %w", err)
|
|
}
|
|
|
|
targetDay := targetDate.Format("2006-01-02")
|
|
|
|
// Collect all wisp IDs that match our criteria
|
|
var wispIDsToDelete []string
|
|
|
|
for _, wisp := range wispList.Wisps {
|
|
// Get full wisp details to check if it's a session.ended event
|
|
showCmd := exec.Command("bd", "show", wisp.ID, "--json")
|
|
showOutput, err := showCmd.Output()
|
|
if err != nil {
|
|
if costsVerbose {
|
|
fmt.Fprintf(os.Stderr, "[costs] bd show failed for wisp %s: %v\n", wisp.ID, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
var events []SessionEvent
|
|
if err := json.Unmarshal(showOutput, &events); err != nil {
|
|
if costsVerbose {
|
|
fmt.Fprintf(os.Stderr, "[costs] JSON unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
continue
|
|
}
|
|
|
|
event := events[0]
|
|
|
|
// Only delete session.ended wisps
|
|
if event.EventKind != "session.ended" {
|
|
continue
|
|
}
|
|
|
|
// Parse payload to get ended_at for date filtering
|
|
var payload SessionPayload
|
|
if event.Payload != "" {
|
|
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
|
|
if costsVerbose {
|
|
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
endedAt := event.CreatedAt
|
|
if payload.EndedAt != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
|
|
endedAt = parsed
|
|
}
|
|
}
|
|
|
|
// Only delete wisps from the target date
|
|
if endedAt.Format("2006-01-02") != targetDay {
|
|
continue
|
|
}
|
|
|
|
wispIDsToDelete = append(wispIDsToDelete, wisp.ID)
|
|
}
|
|
|
|
if len(wispIDsToDelete) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
// Batch delete all wisps in a single subprocess call
|
|
burnArgs := append([]string{"mol", "burn", "--force"}, wispIDsToDelete...)
|
|
burnCmd := exec.Command("bd", burnArgs...)
|
|
if burnErr := burnCmd.Run(); burnErr != nil {
|
|
return 0, fmt.Errorf("batch burn failed: %w", burnErr)
|
|
}
|
|
|
|
return len(wispIDsToDelete), 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 wisp 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 ephemeral wisps + daily digests."))
|
|
|
|
return nil
|
|
}
|