From 370cdc00865bec8fc74d5ecafe4e9128f7aaf633 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Mon, 22 Jun 2026 00:07:06 +0800 Subject: [PATCH] fix(daemon): send claude-code User-Agent to fix persistent statusline quota 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic OAuth usage endpoint (/api/oauth/usage) requires a `User-Agent: claude-code/` header. Without it, requests land in an aggressively rate-limited bucket and return persistent 429s, surfacing as `🚦 err:api:429` in the Claude Code statusline. - Send `User-Agent` (+ Content-Type) on the usage request; flow the real Claude Code version (data.Version) CLI -> daemon via CCInfoRequest, with a fallback. - Add 429-aware backoff: honor Retry-After, otherwise back off 30m, so the daemon stops poking the throttled bucket. - Stop wiping the rate-limit cache on idle (stopTimer) so the last-good usage and the TTL/backoff survive idle cycles. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/cc_statusline.go | 6 +- commands/cc_statusline_test.go | 10 +-- daemon/anthropic_ratelimit.go | 59 +++++++++++++- daemon/anthropic_ratelimit_test.go | 120 +++++++++++++++++++++++++++++ daemon/cc_info_handler_test.go | 10 +-- daemon/cc_info_timer.go | 54 +++++++++++-- daemon/client.go | 10 ++- daemon/client_test.go | 8 +- daemon/socket.go | 10 ++- daemon/socket_extra_test.go | 2 +- 10 files changed, 252 insertions(+), 37 deletions(-) diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index 1efbd76..a2f5d93 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -86,7 +86,7 @@ func commandCCStatusline(c *cli.Context) error { } } - result = getDaemonInfoWithFallback(ctx, config, data.Cwd) + result = getDaemonInfoWithFallback(ctx, config, data.Cwd, data.Version) } // Format and output @@ -304,7 +304,7 @@ func formatSessionDuration(totalSeconds int) string { // getDaemonInfoWithFallback tries to get daily stats and git info from daemon first, // falls back to direct API for stats if daemon is unavailable (git info only from daemon) -func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig, workingDir string) ccStatuslineResult { +func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig, workingDir, claudeCodeVersion string) ccStatuslineResult { socketPath := config.SocketPath if socketPath == "" { socketPath = model.DefaultSocketPath @@ -312,7 +312,7 @@ func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig // Try daemon first (50ms timeout for fast path) if daemon.IsSocketReady(ctx, socketPath) { - resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond) + resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, claudeCodeVersion, 50*time.Millisecond) if err == nil && resp != nil { return ccStatuslineResult{ Cost: resp.TotalCostUSD, diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index e61c022..bda7889 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -79,7 +79,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDaemonWhenAvailable() { SocketPath: s.socketPath, } - result := getDaemonInfoWithFallback(context.Background(), config, "/some/path") + result := getDaemonInfoWithFallback(context.Background(), config, "/some/path", "") assert.Equal(s.T(), expectedCost, result.Cost) assert.Equal(s.T(), expectedSessionSeconds, result.SessionSeconds) @@ -94,7 +94,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_FallbackWhenDaemonUnavailable( Token: "", // No token means FetchDailyStatsCached returns zero values } - result := getDaemonInfoWithFallback(context.Background(), config, "") + result := getDaemonInfoWithFallback(context.Background(), config, "", "") // Should return zero values (from cache fallback with no token) assert.Equal(s.T(), float64(0), result.Cost) @@ -122,7 +122,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_FallbackOnDaemonError() { Token: "", // No token } - result := getDaemonInfoWithFallback(context.Background(), config, "") + result := getDaemonInfoWithFallback(context.Background(), config, "", "") // Should fall back and return zero values assert.Equal(s.T(), float64(0), result.Cost) @@ -141,7 +141,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDefaultSocketPath() { // This should use model.DefaultSocketPath internally // Since no daemon is running at the default path, it will fall back to cached API // The function should not panic and should return a valid result struct - result := getDaemonInfoWithFallback(context.Background(), config, "") + result := getDaemonInfoWithFallback(context.Background(), config, "", "") // We can't assert on exact values since the global cache might have data // from previous tests. Just verify the function returns without error @@ -582,7 +582,7 @@ func (s *CCStatuslineTestSuite) TestGetDaemonInfo_PropagatesRateLimitFields() { SocketPath: s.socketPath, } - result := getDaemonInfoWithFallback(context.Background(), config, "/some/path") + result := getDaemonInfoWithFallback(context.Background(), config, "/some/path", "") assert.NotNil(s.T(), result.FiveHourUtilization) assert.NotNil(s.T(), result.SevenDayUtilization) diff --git a/daemon/anthropic_ratelimit.go b/daemon/anthropic_ratelimit.go index 1077ffe..ea515c8 100644 --- a/daemon/anthropic_ratelimit.go +++ b/daemon/anthropic_ratelimit.go @@ -9,12 +9,26 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" + "strings" "sync" "time" ) const anthropicUsageCacheTTL = 10 * time.Minute +// anthropicRateLimitBackoff is the minimum cooldown applied after a 429 from the usage API, +// used when the response carries no (or a shorter) Retry-After. It is longer than the normal +// TTL so the daemon stops poking the throttled bucket. +const anthropicRateLimitBackoff = 30 * time.Minute + +// claudeCodeFallbackVersion is used in the User-Agent when the real Claude Code version is +// unknown. The usage endpoint gates on the "claude-code/" prefix, not the exact version. +const claudeCodeFallbackVersion = "2.0.0" + +// anthropicUsageURL is the OAuth usage endpoint. It is a var (not a const) so tests can override it. +var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage" + // AnthropicRateLimitData holds the parsed rate limit utilization data type AnthropicRateLimitData struct { FiveHourUtilization float64 @@ -28,7 +42,19 @@ type anthropicRateLimitCache struct { usage *AnthropicRateLimitData fetchedAt time.Time lastAttemptAt time.Time - lastError string // short error description for statusline display + backoffUntil time.Time // when set in the future, skip fetching (e.g. after a 429) + lastError string // short error description for statusline display +} + +// anthropicAPIError represents a non-200 response from the Anthropic usage API. +// Error() keeps the historical message format so shortenAPIError still produces "api:". +type anthropicAPIError struct { + StatusCode int + RetryAfter time.Duration // parsed from the Retry-After header; 0 if absent/unparseable +} + +func (e *anthropicAPIError) Error() string { + return fmt.Sprintf("anthropic usage API returned status %d", e.StatusCode) } // anthropicUsageResponse maps the Anthropic API response @@ -111,14 +137,23 @@ func parseOAuthTokenFromJSON(data []byte) (string, error) { } // fetchAnthropicUsage calls the Anthropic OAuth usage API and returns rate limit data. -func fetchAnthropicUsage(ctx context.Context, token string) (*AnthropicRateLimitData, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/api/oauth/usage", nil) +// version is the Claude Code version used for the User-Agent header; when empty it falls back +// to claudeCodeFallbackVersion. The endpoint requires a "claude-code/" User-Agent: +// without it, requests land in an aggressively rate-limited bucket and return persistent 429s. +func fetchAnthropicUsage(ctx context.Context, token, version string) (*AnthropicRateLimitData, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, anthropicUsageURL, nil) if err != nil { return nil, err } + if version == "" { + version = claudeCodeFallbackVersion + } + req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("anthropic-beta", "oauth-2025-04-20") + req.Header.Set("User-Agent", "claude-code/"+version) + req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) @@ -128,7 +163,10 @@ func fetchAnthropicUsage(ctx context.Context, token string) (*AnthropicRateLimit defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("anthropic usage API returned status %d", resp.StatusCode) + return nil, &anthropicAPIError{ + StatusCode: resp.StatusCode, + RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")), + } } var usage anthropicUsageResponse @@ -143,3 +181,16 @@ func fetchAnthropicUsage(ctx context.Context, token string) (*AnthropicRateLimit SevenDayResetsAt: usage.SevenDay.ResetsAt, }, nil } + +// parseRetryAfter parses an HTTP Retry-After header in delay-seconds form. +// Returns 0 when the value is absent or not a positive integer (HTTP-date form is not used here). +func parseRetryAfter(v string) time.Duration { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + return time.Duration(secs) * time.Second + } + return 0 +} diff --git a/daemon/anthropic_ratelimit_test.go b/daemon/anthropic_ratelimit_test.go index 04e037d..c0e66d6 100644 --- a/daemon/anthropic_ratelimit_test.go +++ b/daemon/anthropic_ratelimit_test.go @@ -3,17 +3,137 @@ package daemon import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" + "time" "github.com/malamtime/cli/model" "github.com/stretchr/testify/assert" ) +// withTestUsageURL points fetchAnthropicUsage at a test server for the duration of the test. +func withTestUsageURL(t *testing.T, url string) { + t.Helper() + orig := anthropicUsageURL + anthropicUsageURL = url + t.Cleanup(func() { anthropicUsageURL = orig }) +} + +func TestFetchAnthropicUsage_SetsClaudeCodeUserAgent(t *testing.T) { + var gotUA, gotCT string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + gotCT = r.Header.Get("Content-Type") + assert.Equal(t, "Bearer tok", r.Header.Get("Authorization")) + assert.Equal(t, "oauth-2025-04-20", r.Header.Get("anthropic-beta")) + json.NewEncoder(w).Encode(anthropicUsageResponse{}) + })) + defer server.Close() + withTestUsageURL(t, server.URL) + + _, err := fetchAnthropicUsage(context.Background(), "tok", "9.9.9") + assert.NoError(t, err) + assert.Equal(t, "claude-code/9.9.9", gotUA) + assert.Equal(t, "application/json", gotCT) +} + +func TestFetchAnthropicUsage_UserAgentFallback(t *testing.T) { + var gotUA string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + json.NewEncoder(w).Encode(anthropicUsageResponse{}) + })) + defer server.Close() + withTestUsageURL(t, server.URL) + + _, err := fetchAnthropicUsage(context.Background(), "tok", "") + assert.NoError(t, err) + assert.Equal(t, "claude-code/"+claudeCodeFallbackVersion, gotUA) +} + +func TestFetchAnthropicUsage_429ReturnsTypedErrorWithRetryAfter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + withTestUsageURL(t, server.URL) + + _, err := fetchAnthropicUsage(context.Background(), "tok", "1.0.0") + assert.Error(t, err) + + var apiErr *anthropicAPIError + assert.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusTooManyRequests, apiErr.StatusCode) + assert.Equal(t, 120*time.Second, apiErr.RetryAfter) + + // The typed error must still shorten to the historical "api:429" for the statusline. + assert.Equal(t, "api:429", shortenAPIError(err)) +} + +func TestParseRetryAfter(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"", 0}, + {"120", 120 * time.Second}, + {" 60 ", 60 * time.Second}, + {"0", 0}, + {"-5", 0}, + {"abc", 0}, + {"Wed, 21 Oct 2026 07:28:00 GMT", 0}, // HTTP-date form is not parsed + } + for _, c := range cases { + assert.Equalf(t, c.want, parseRetryAfter(c.in), "parseRetryAfter(%q)", c.in) + } +} + +func TestSetGetClaudeCodeVersion(t *testing.T) { + service := NewCCInfoTimerService(&model.ShellTimeConfig{}) + assert.Empty(t, service.GetClaudeCodeVersion()) + + service.SetClaudeCodeVersion("3.1.4") + assert.Equal(t, "3.1.4", service.GetClaudeCodeVersion()) + + // Empty values are ignored so a known version is not clobbered. + service.SetClaudeCodeVersion("") + assert.Equal(t, "3.1.4", service.GetClaudeCodeVersion()) +} + +func TestStopTimer_PreservesRateLimitCache(t *testing.T) { + service := NewCCInfoTimerService(&model.ShellTimeConfig{}) + + // Seed a good rate-limit cache. + service.rateLimitCache.mu.Lock() + service.rateLimitCache.usage = &AnthropicRateLimitData{FiveHourUtilization: 0.5} + service.rateLimitCache.fetchedAt = time.Now() + service.rateLimitCache.lastAttemptAt = time.Now() + service.rateLimitCache.backoffUntil = time.Now().Add(time.Hour) + service.rateLimitCache.mu.Unlock() + + // stopTimer must be called with timerMu held and the timer "running". + service.timerMu.Lock() + service.timerRunning = true + service.ticker = time.NewTicker(time.Hour) + service.stopTimer() + service.timerMu.Unlock() + + // The cache must survive an idle stop so the TTL/backoff hold across idle cycles. + rl := service.GetCachedRateLimit() + assert.NotNil(t, rl) + assert.Equal(t, 0.5, rl.FiveHourUtilization) + + service.rateLimitCache.mu.RLock() + assert.False(t, service.rateLimitCache.backoffUntil.IsZero()) + service.rateLimitCache.mu.RUnlock() +} + func TestFetchAnthropicUsage_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) diff --git a/daemon/cc_info_handler_test.go b/daemon/cc_info_handler_test.go index e92f802..3114b4e 100644 --- a/daemon/cc_info_handler_test.go +++ b/daemon/cc_info_handler_test.go @@ -232,7 +232,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Success() { // Give server time to start time.Sleep(10 * time.Millisecond) - response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 1*time.Second) + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", "", 1*time.Second) assert.NoError(s.T(), err) assert.NotNil(s.T(), response) @@ -255,14 +255,14 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Timeout() { time.Sleep(10 * time.Millisecond) - response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 50*time.Millisecond) + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", "", 50*time.Millisecond) assert.Error(s.T(), err) assert.Nil(s.T(), response) } func (s *CCInfoClientTestSuite) TestRequestCCInfo_SocketNotFound() { - response, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", 100*time.Millisecond) + response, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", "", 100*time.Millisecond) assert.Error(s.T(), err) assert.Nil(s.T(), response) @@ -287,7 +287,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_InvalidResponse() { time.Sleep(10 * time.Millisecond) - response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 1*time.Second) + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", "", 1*time.Second) assert.Error(s.T(), err) assert.Nil(s.T(), response) @@ -312,7 +312,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_SendsCorrectMessage() { time.Sleep(10 * time.Millisecond) - RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, "", 1*time.Second) + RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, "", "", 1*time.Second) assert.Equal(s.T(), SocketMessageTypeCCInfo, receivedMsg.Type) diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index 448defd..b34154e 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -2,8 +2,10 @@ package daemon import ( "context" + "errors" "fmt" "log/slog" + "net/http" "path/filepath" "runtime" "sync" @@ -58,6 +60,9 @@ type CCInfoTimerService struct { // User profile cache (permanent for daemon lifetime) userLogin string userLoginFetched bool + + // Claude Code version reported by the statusline client, used for the Anthropic usage User-Agent + claudeCodeVersion string } // NewCCInfoTimerService creates a new CC info timer service @@ -142,16 +147,14 @@ func (s *CCInfoTimerService) stopTimer() { s.ticker.Stop() s.timerRunning = false - // Clear active ranges, git cache, and rate limit cache when stopping + // Clear active ranges and git cache when stopping. + // The Anthropic rate-limit cache is intentionally preserved across idle cycles: it keeps the + // last good usage for instant display and, crucially, retains fetchedAt/lastAttemptAt/backoffUntil + // so the TTL and 429 backoff hold instead of re-fetching immediately on the next activity. s.mu.Lock() s.activeRanges = make(map[CCInfoTimeRange]bool) s.gitCache = make(map[string]*GitCacheEntry) s.mu.Unlock() - s.rateLimitCache.mu.Lock() - s.rateLimitCache.usage = nil - s.rateLimitCache.fetchedAt = time.Time{} - s.rateLimitCache.lastAttemptAt = time.Time{} - s.rateLimitCache.mu.Unlock() slog.Info("CC info timer stopped due to inactivity") } @@ -399,12 +402,18 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { return } - // Check cache TTL under read lock - skip if data is fresh or we attempted recently + // Check cache TTL under read lock - skip if data is fresh, we attempted recently, or we are + // in a 429 backoff window. s.rateLimitCache.mu.RLock() sinceLastFetch := time.Since(s.rateLimitCache.fetchedAt) sinceLastAttempt := time.Since(s.rateLimitCache.lastAttemptAt) + backoffUntil := s.rateLimitCache.backoffUntil s.rateLimitCache.mu.RUnlock() + if !backoffUntil.IsZero() && time.Now().Before(backoffUntil) { + return + } + if sinceLastFetch < anthropicUsageCacheTTL || sinceLastAttempt < anthropicUsageCacheTTL { return } @@ -424,11 +433,21 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { return } - usage, err := fetchAnthropicUsage(ctx, token) + usage, err := fetchAnthropicUsage(ctx, token, s.GetClaudeCodeVersion()) if err != nil { slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) s.rateLimitCache.mu.Lock() s.rateLimitCache.lastError = shortenAPIError(err) + // On rate limiting, back off longer than the normal TTL to avoid hammering the throttled + // bucket. Honor Retry-After when provided, otherwise use the default backoff. + var apiErr *anthropicAPIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests { + backoff := apiErr.RetryAfter + if backoff < anthropicRateLimitBackoff { + backoff = anthropicRateLimitBackoff + } + s.rateLimitCache.backoffUntil = time.Now().Add(backoff) + } s.rateLimitCache.mu.Unlock() return } @@ -437,6 +456,7 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { s.rateLimitCache.usage = usage s.rateLimitCache.fetchedAt = time.Now() s.rateLimitCache.lastError = "" + s.rateLimitCache.backoffUntil = time.Time{} s.rateLimitCache.mu.Unlock() // Send usage data to server for push notification scheduling (fire-and-forget) @@ -459,6 +479,24 @@ func (s *CCInfoTimerService) GetCachedUserLogin() string { return s.userLogin } +// SetClaudeCodeVersion records the Claude Code version reported by the statusline client. +// Empty values are ignored so a known version is not overwritten by a client that omits it. +func (s *CCInfoTimerService) SetClaudeCodeVersion(version string) { + if version == "" { + return + } + s.mu.Lock() + s.claudeCodeVersion = version + s.mu.Unlock() +} + +// GetClaudeCodeVersion returns the last reported Claude Code version, or "" if unknown. +func (s *CCInfoTimerService) GetClaudeCodeVersion() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.claudeCodeVersion +} + // fetchUserProfile fetches the current user's login once per daemon lifetime. func (s *CCInfoTimerService) fetchUserProfile(ctx context.Context) { if s.config.Token == "" { diff --git a/daemon/client.go b/daemon/client.go index 2295b02..08e4fa3 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -126,8 +126,9 @@ func RequestListCommands(socketPath string, timeout time.Duration) (*ListCommand return &response, nil } -// RequestCCInfo requests CC info (cost data and git info) from the daemon -func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir string, timeout time.Duration) (*CCInfoResponse, error) { +// RequestCCInfo requests CC info (cost data and git info) from the daemon. +// claudeCodeVersion is forwarded so the daemon can use it in the Anthropic usage User-Agent. +func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir, claudeCodeVersion string, timeout time.Duration) (*CCInfoResponse, error) { conn, err := net.DialTimeout("unix", socketPath, timeout) if err != nil { return nil, err @@ -141,8 +142,9 @@ func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir stri msg := SocketMessage{ Type: SocketMessageTypeCCInfo, Payload: CCInfoRequest{ - TimeRange: timeRange, - WorkingDirectory: workingDir, + TimeRange: timeRange, + WorkingDirectory: workingDir, + ClaudeCodeVersion: claudeCodeVersion, }, } diff --git a/daemon/client_test.go b/daemon/client_test.go index 78c6add..8c4494e 100644 --- a/daemon/client_test.go +++ b/daemon/client_test.go @@ -157,7 +157,7 @@ func TestRequestCCInfo(t *testing.T) { // Give server time to start time.Sleep(50 * time.Millisecond) - response, err := RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 5*time.Second) + response, err := RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", "", 5*time.Second) if err != nil { t.Fatalf("RequestCCInfo failed: %v", err) } @@ -200,14 +200,14 @@ func TestRequestCCInfo_Timeout(t *testing.T) { // Give server time to start time.Sleep(50 * time.Millisecond) - _, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 100*time.Millisecond) + _, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", "", 100*time.Millisecond) if err == nil { t.Error("Expected timeout error") } } func TestRequestCCInfo_SocketNotExists(t *testing.T) { - _, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", 1*time.Second) + _, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", "", 1*time.Second) if err == nil { t.Error("Expected error when socket doesn't exist") } @@ -261,7 +261,7 @@ func TestRequestCCInfo_AllTimeRanges(t *testing.T) { time.Sleep(50 * time.Millisecond) - response, err := RequestCCInfo(socketPath, timeRange, "", 5*time.Second) + response, err := RequestCCInfo(socketPath, timeRange, "", "", 5*time.Second) if err != nil { t.Fatalf("RequestCCInfo failed: %v", err) } diff --git a/daemon/socket.go b/daemon/socket.go index e66406e..1c33db6 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -60,8 +60,9 @@ const ( ) type CCInfoRequest struct { - TimeRange CCInfoTimeRange `json:"timeRange"` - WorkingDirectory string `json:"workingDirectory"` + TimeRange CCInfoTimeRange `json:"timeRange"` + WorkingDirectory string `json:"workingDirectory"` + ClaudeCodeVersion string `json:"claudeCodeVersion,omitempty"` } type CCInfoResponse struct { @@ -271,7 +272,7 @@ func (p *SocketHandler) handleListCommands(conn net.Conn) { func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { slog.Debug("cc_info socket event received") - // Parse time range and working directory from payload + // Parse time range, working directory, and Claude Code version from payload timeRange := CCInfoTimeRangeToday var workingDir string if payload, ok := msg.Payload.(map[string]interface{}); ok { @@ -281,6 +282,9 @@ func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { if wd, ok := payload["workingDirectory"].(string); ok { workingDir = wd } + if v, ok := payload["claudeCodeVersion"].(string); ok { + p.ccInfoTimer.SetClaudeCodeVersion(v) + } } // Get cached cost first (marks range as active), then notify activity (starts timer) diff --git a/daemon/socket_extra_test.go b/daemon/socket_extra_test.go index 5ec60b7..d1ccf70 100644 --- a/daemon/socket_extra_test.go +++ b/daemon/socket_extra_test.go @@ -192,7 +192,7 @@ func TestRequestCCInfo_OverRealHandler(t *testing.T) { // (rather than a stub responder), exercising the server-side encode logic. _, socketPath := startHandler(t, &model.ShellTimeConfig{}) - resp, err := RequestCCInfo(socketPath, CCInfoTimeRangeWeek, "", 2*time.Second) + resp, err := RequestCCInfo(socketPath, CCInfoTimeRangeWeek, "", "", 2*time.Second) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, "week", resp.TimeRange)