Skip to content

Commit 8dac5fb

Browse files
AnnatarHeclaude
andcommitted
feat(daemon): add cc_info socket handler with lazy timer for statusline
- Add cc_info socket message type with time range parameter (today/week/month) - Create CCInfoTimerService with lazy timer pattern: - 3-second fetch interval when active - 3-minute inactivity timeout to stop timer - Cache per requested time range - CLI now requests daily cost from daemon first with 50ms timeout - Falls back to direct API call if daemon unavailable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b994613 commit 8dac5fb

4 files changed

Lines changed: 360 additions & 7 deletions

File tree

commands/cc_statusline.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/gookit/color"
14+
"github.com/malamtime/cli/daemon"
1415
"github.com/malamtime/cli/model"
1516
"github.com/urfave/cli/v2"
1617
)
@@ -43,11 +44,11 @@ func commandCCStatusline(c *cli.Context) error {
4344
// Calculate context percentage
4445
contextPercent := calculateContextPercent(data.ContextWindow)
4546

46-
// Get daily cost (cached) - need to read config first
47+
// Get daily cost - try daemon first, fallback to direct API
4748
var dailyCost float64
4849
config, err := configService.ReadConfigFile(ctx)
4950
if err == nil {
50-
dailyCost = model.FetchDailyCostCached(ctx, config)
51+
dailyCost = getDailyCostWithDaemonFallback(ctx, config)
5152
}
5253

5354
// Format and output
@@ -144,3 +145,23 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost, contextPer
144145
func outputFallback() {
145146
fmt.Println(color.Gray.Sprint("🤖 - | 💰 - | 📊 - | 📈 -%"))
146147
}
148+
149+
// getDailyCostWithDaemonFallback tries to get daily cost from daemon first,
150+
// falls back to direct API if daemon is unavailable
151+
func getDailyCostWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) float64 {
152+
socketPath := config.SocketPath
153+
if socketPath == "" {
154+
socketPath = model.DefaultSocketPath
155+
}
156+
157+
// Try daemon first (50ms timeout for fast path)
158+
if daemon.IsSocketReady(ctx, socketPath) {
159+
resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, 50*time.Millisecond)
160+
if err == nil && resp != nil {
161+
return resp.TotalCostUSD
162+
}
163+
}
164+
165+
// Fallback to direct API (existing behavior)
166+
return model.FetchDailyCostCached(ctx, config)
167+
}

daemon/cc_info_timer.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"sync"
7+
"time"
8+
9+
"github.com/malamtime/cli/model"
10+
)
11+
12+
const (
13+
CCInfoFetchInterval = 3 * time.Second
14+
CCInfoInactivityTimeout = 3 * time.Minute
15+
)
16+
17+
// CCInfoCache holds the cached cost data for a time range
18+
type CCInfoCache struct {
19+
TotalCostUSD float64
20+
FetchedAt time.Time
21+
}
22+
23+
// CCInfoTimerService manages lazy-fetching of CC info data
24+
type CCInfoTimerService struct {
25+
config *model.ShellTimeConfig
26+
27+
mu sync.RWMutex
28+
cache map[CCInfoTimeRange]CCInfoCache
29+
activeRanges map[CCInfoTimeRange]bool
30+
lastActivity time.Time
31+
32+
timerMu sync.Mutex
33+
timerRunning bool
34+
ticker *time.Ticker
35+
stopChan chan struct{}
36+
wg sync.WaitGroup
37+
}
38+
39+
// NewCCInfoTimerService creates a new CC info timer service
40+
func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService {
41+
return &CCInfoTimerService{
42+
config: config,
43+
cache: make(map[CCInfoTimeRange]CCInfoCache),
44+
activeRanges: make(map[CCInfoTimeRange]bool),
45+
stopChan: make(chan struct{}),
46+
}
47+
}
48+
49+
// GetCachedCost returns the cached cost for the given time range
50+
// It also marks the range as active and starts the timer if not running
51+
func (s *CCInfoTimerService) GetCachedCost(timeRange CCInfoTimeRange) CCInfoCache {
52+
s.mu.Lock()
53+
s.activeRanges[timeRange] = true
54+
cache := s.cache[timeRange]
55+
s.mu.Unlock()
56+
57+
return cache
58+
}
59+
60+
// NotifyActivity signals that a client has requested data
61+
// This starts the timer if not running, or resets the inactivity timeout
62+
func (s *CCInfoTimerService) NotifyActivity() {
63+
s.mu.Lock()
64+
s.lastActivity = time.Now()
65+
s.mu.Unlock()
66+
67+
s.timerMu.Lock()
68+
defer s.timerMu.Unlock()
69+
70+
if !s.timerRunning {
71+
s.startTimer()
72+
}
73+
}
74+
75+
// Stop gracefully stops the timer service
76+
func (s *CCInfoTimerService) Stop() {
77+
s.timerMu.Lock()
78+
if s.timerRunning {
79+
s.ticker.Stop()
80+
s.timerRunning = false
81+
}
82+
s.timerMu.Unlock()
83+
84+
select {
85+
case <-s.stopChan:
86+
// Already closed
87+
default:
88+
close(s.stopChan)
89+
}
90+
91+
s.wg.Wait()
92+
slog.Info("CC info timer service stopped")
93+
}
94+
95+
// startTimer starts the timer loop (must be called with timerMu held)
96+
func (s *CCInfoTimerService) startTimer() {
97+
if s.timerRunning {
98+
return
99+
}
100+
101+
s.timerRunning = true
102+
s.ticker = time.NewTicker(CCInfoFetchInterval)
103+
s.wg.Add(1)
104+
105+
go s.timerLoop()
106+
107+
slog.Info("CC info timer started")
108+
}
109+
110+
// stopTimer stops the timer (must be called with timerMu held)
111+
func (s *CCInfoTimerService) stopTimer() {
112+
if !s.timerRunning {
113+
return
114+
}
115+
116+
s.ticker.Stop()
117+
s.timerRunning = false
118+
119+
// Clear active ranges when stopping
120+
s.mu.Lock()
121+
s.activeRanges = make(map[CCInfoTimeRange]bool)
122+
s.mu.Unlock()
123+
124+
slog.Info("CC info timer stopped due to inactivity")
125+
}
126+
127+
// timerLoop runs the timer loop
128+
func (s *CCInfoTimerService) timerLoop() {
129+
defer s.wg.Done()
130+
131+
// Fetch immediately on start
132+
s.fetchActiveRanges(context.Background())
133+
134+
for {
135+
select {
136+
case <-s.ticker.C:
137+
// Check inactivity before fetching
138+
if s.checkInactivity() {
139+
s.timerMu.Lock()
140+
s.stopTimer()
141+
s.timerMu.Unlock()
142+
return
143+
}
144+
s.fetchActiveRanges(context.Background())
145+
146+
case <-s.stopChan:
147+
return
148+
}
149+
}
150+
}
151+
152+
// checkInactivity returns true if the service has been inactive for too long
153+
func (s *CCInfoTimerService) checkInactivity() bool {
154+
s.mu.RLock()
155+
lastActivity := s.lastActivity
156+
s.mu.RUnlock()
157+
158+
return time.Since(lastActivity) > CCInfoInactivityTimeout
159+
}
160+
161+
// fetchActiveRanges fetches data for all active time ranges
162+
func (s *CCInfoTimerService) fetchActiveRanges(ctx context.Context) {
163+
if s.config.Token == "" {
164+
return
165+
}
166+
167+
// Get active ranges
168+
s.mu.RLock()
169+
ranges := make([]CCInfoTimeRange, 0, len(s.activeRanges))
170+
for r := range s.activeRanges {
171+
ranges = append(ranges, r)
172+
}
173+
s.mu.RUnlock()
174+
175+
// Fetch each active range
176+
for _, timeRange := range ranges {
177+
cost, err := s.fetchCost(ctx, timeRange)
178+
if err != nil {
179+
slog.Warn("Failed to fetch CC info cost",
180+
slog.String("timeRange", string(timeRange)),
181+
slog.Any("err", err))
182+
continue
183+
}
184+
185+
s.mu.Lock()
186+
s.cache[timeRange] = CCInfoCache{
187+
TotalCostUSD: cost,
188+
FetchedAt: time.Now(),
189+
}
190+
s.mu.Unlock()
191+
192+
slog.Debug("CC info cost updated",
193+
slog.String("timeRange", string(timeRange)),
194+
slog.Float64("cost", cost))
195+
}
196+
}
197+
198+
// fetchCost fetches the cost for a specific time range
199+
func (s *CCInfoTimerService) fetchCost(ctx context.Context, timeRange CCInfoTimeRange) (float64, error) {
200+
now := time.Now()
201+
var since time.Time
202+
203+
switch timeRange {
204+
case CCInfoTimeRangeToday:
205+
since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
206+
case CCInfoTimeRangeWeek:
207+
// Start of current week (Monday)
208+
weekday := int(now.Weekday())
209+
if weekday == 0 {
210+
weekday = 7 // Sunday is 7
211+
}
212+
since = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
213+
case CCInfoTimeRangeMonth:
214+
since = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
215+
default:
216+
// Default to today
217+
since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
218+
}
219+
220+
variables := map[string]interface{}{
221+
"filter": map[string]interface{}{
222+
"since": since.Format(time.RFC3339),
223+
"until": now.Format(time.RFC3339),
224+
"clientType": "claude_code",
225+
},
226+
}
227+
228+
var result model.GraphQLResponse[model.CCStatuslineDailyCostResponse]
229+
230+
err := model.SendGraphQLRequest(model.GraphQLRequestOptions[model.GraphQLResponse[model.CCStatuslineDailyCostResponse]]{
231+
Context: ctx,
232+
Endpoint: model.Endpoint{
233+
Token: s.config.Token,
234+
APIEndpoint: s.config.APIEndpoint,
235+
},
236+
Query: model.CCStatuslineDailyCostQuery,
237+
Variables: variables,
238+
Response: &result,
239+
Timeout: 5 * time.Second,
240+
})
241+
242+
if err != nil {
243+
return 0, err
244+
}
245+
246+
return result.Data.FetchUser.AICodeOtel.Analytics.TotalCostUsd, nil
247+
}

daemon/client.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,37 @@ func SendLocalDataToSocket(
5050

5151
return nil
5252
}
53+
54+
// RequestCCInfo requests CC info (cost data) from the daemon
55+
func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Duration) (*CCInfoResponse, error) {
56+
conn, err := net.DialTimeout("unix", socketPath, timeout)
57+
if err != nil {
58+
return nil, err
59+
}
60+
defer conn.Close()
61+
62+
// Set read/write deadline
63+
conn.SetDeadline(time.Now().Add(timeout))
64+
65+
// Send request
66+
msg := SocketMessage{
67+
Type: SocketMessageTypeCCInfo,
68+
Payload: CCInfoRequest{
69+
TimeRange: timeRange,
70+
},
71+
}
72+
73+
encoder := json.NewEncoder(conn)
74+
if err := encoder.Encode(msg); err != nil {
75+
return nil, err
76+
}
77+
78+
// Read response
79+
var response CCInfoResponse
80+
decoder := json.NewDecoder(conn)
81+
if err := decoder.Decode(&response); err != nil {
82+
return nil, err
83+
}
84+
85+
return &response, nil
86+
}

0 commit comments

Comments
 (0)