Skip to content

Commit d2fab94

Browse files
authored
Merge pull request #264 from shelltime/codex/decouple-codex-usage-sync
[codex] decouple Codex usage sync from CC statusline
2 parents 3fa04f0 + d1b334b commit d2fab94

5 files changed

Lines changed: 521 additions & 148 deletions

File tree

cmd/daemon/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,25 @@ func main() {
148148
}
149149
}
150150

151+
codexInstalled, err := daemon.CodexInstallationStatus()
152+
if err != nil {
153+
if reason, ok := daemon.CodexSyncSkipReason(err); ok {
154+
slog.Info("Skipping Codex usage sync service startup", slog.String("reason", reason))
155+
} else {
156+
slog.Error("Failed to check Codex installation status", slog.Any("err", err))
157+
}
158+
} else if !codexInstalled {
159+
slog.Info("Skipping Codex usage sync service startup", slog.String("reason", "codex_not_configured"))
160+
} else {
161+
codexUsageSyncService := daemon.NewCodexUsageSyncService(cfg)
162+
if err := codexUsageSyncService.Start(ctx); err != nil {
163+
slog.Error("Failed to start Codex usage sync service", slog.Any("err", err))
164+
} else {
165+
slog.Info("Codex usage sync service started")
166+
defer codexUsageSyncService.Stop()
167+
}
168+
}
169+
151170
// Create processor instance
152171
processor := daemon.NewSocketHandler(&cfg, pubsub)
153172

daemon/cc_info_timer.go

Lines changed: 1 addition & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ type CCInfoTimerService struct {
5555
// Anthropic rate limit cache
5656
rateLimitCache *anthropicRateLimitCache
5757

58-
// Codex rate limit cache
59-
codexRateLimitCache *codexRateLimitCache
60-
6158
// User profile cache (permanent for daemon lifetime)
6259
userLogin string
6360
userLoginFetched bool
@@ -70,8 +67,7 @@ func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService {
7067
cache: make(map[CCInfoTimeRange]CCInfoCache),
7168
activeRanges: make(map[CCInfoTimeRange]bool),
7269
gitCache: make(map[string]*GitCacheEntry),
73-
rateLimitCache: &anthropicRateLimitCache{},
74-
codexRateLimitCache: &codexRateLimitCache{},
70+
rateLimitCache: &anthropicRateLimitCache{},
7571
stopChan: make(chan struct{}),
7672
}
7773
}
@@ -156,11 +152,6 @@ func (s *CCInfoTimerService) stopTimer() {
156152
s.rateLimitCache.fetchedAt = time.Time{}
157153
s.rateLimitCache.lastAttemptAt = time.Time{}
158154
s.rateLimitCache.mu.Unlock()
159-
s.codexRateLimitCache.mu.Lock()
160-
s.codexRateLimitCache.usage = nil
161-
s.codexRateLimitCache.fetchedAt = time.Time{}
162-
s.codexRateLimitCache.lastAttemptAt = time.Time{}
163-
s.codexRateLimitCache.mu.Unlock()
164155

165156
slog.Info("CC info timer stopped due to inactivity")
166157
}
@@ -180,7 +171,6 @@ func (s *CCInfoTimerService) timerLoop() {
180171
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
181172
defer cancel()
182173
s.fetchRateLimit(ctx)
183-
s.fetchCodexRateLimit(ctx)
184174
}()
185175
go s.fetchUserProfile(context.Background())
186176

@@ -204,7 +194,6 @@ func (s *CCInfoTimerService) timerLoop() {
204194
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
205195
defer cancel()
206196
s.fetchRateLimit(ctx)
207-
s.fetchCodexRateLimit(ctx)
208197
}()
209198

210199
case <-s.stopChan:
@@ -562,138 +551,6 @@ func (s *CCInfoTimerService) GetCachedRateLimitError() string {
562551
return s.rateLimitCache.lastError
563552
}
564553

565-
// fetchCodexRateLimit fetches Codex rate limit data if cache is stale.
566-
func (s *CCInfoTimerService) fetchCodexRateLimit(ctx context.Context) {
567-
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
568-
return
569-
}
570-
571-
// Check cache TTL under read lock
572-
s.codexRateLimitCache.mu.RLock()
573-
sinceLastFetch := time.Since(s.codexRateLimitCache.fetchedAt)
574-
sinceLastAttempt := time.Since(s.codexRateLimitCache.lastAttemptAt)
575-
s.codexRateLimitCache.mu.RUnlock()
576-
577-
if sinceLastFetch < codexUsageCacheTTL || sinceLastAttempt < codexUsageCacheTTL {
578-
return
579-
}
580-
581-
// Record attempt time
582-
s.codexRateLimitCache.mu.Lock()
583-
s.codexRateLimitCache.lastAttemptAt = time.Now()
584-
s.codexRateLimitCache.mu.Unlock()
585-
586-
auth, err := loadCodexAuth()
587-
if err != nil || auth == nil {
588-
slog.Debug("Failed to load Codex auth", slog.Any("err", err))
589-
s.codexRateLimitCache.mu.Lock()
590-
s.codexRateLimitCache.lastError = "auth"
591-
s.codexRateLimitCache.mu.Unlock()
592-
return
593-
}
594-
595-
usage, err := fetchCodexUsage(ctx, auth)
596-
if err != nil {
597-
slog.Warn("Failed to fetch Codex usage", slog.Any("err", err))
598-
s.codexRateLimitCache.mu.Lock()
599-
s.codexRateLimitCache.lastError = shortenCodexAPIError(err)
600-
s.codexRateLimitCache.mu.Unlock()
601-
return
602-
}
603-
604-
s.codexRateLimitCache.mu.Lock()
605-
s.codexRateLimitCache.usage = usage
606-
s.codexRateLimitCache.fetchedAt = time.Now()
607-
s.codexRateLimitCache.lastError = ""
608-
s.codexRateLimitCache.mu.Unlock()
609-
610-
// Send usage data to server (fire-and-forget)
611-
go func() {
612-
bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second)
613-
defer bgCancel()
614-
s.sendCodexUsageToServer(bgCtx, usage)
615-
}()
616-
617-
slog.Debug("Codex rate limit updated",
618-
slog.String("plan", usage.Plan),
619-
slog.Int("windows", len(usage.Windows)))
620-
}
621-
622-
// sendCodexUsageToServer sends Codex usage data to the ShellTime server
623-
// for scheduling push notifications when rate limits reset.
624-
func (s *CCInfoTimerService) sendCodexUsageToServer(ctx context.Context, usage *CodexRateLimitData) {
625-
if s.config.Token == "" {
626-
return
627-
}
628-
629-
type usageWindow struct {
630-
LimitID string `json:"limit_id"`
631-
UsagePercentage float64 `json:"usage_percentage"`
632-
ResetsAt string `json:"resets_at"`
633-
WindowDurationMinutes int `json:"window_duration_minutes"`
634-
}
635-
type usagePayload struct {
636-
Plan string `json:"plan"`
637-
Windows []usageWindow `json:"windows"`
638-
}
639-
640-
windows := make([]usageWindow, len(usage.Windows))
641-
for i, w := range usage.Windows {
642-
windows[i] = usageWindow{
643-
LimitID: w.LimitID,
644-
UsagePercentage: w.UsagePercentage,
645-
ResetsAt: time.Unix(w.ResetAt, 0).UTC().Format(time.RFC3339),
646-
WindowDurationMinutes: w.WindowDurationMinutes,
647-
}
648-
}
649-
650-
payload := usagePayload{
651-
Plan: usage.Plan,
652-
Windows: windows,
653-
}
654-
655-
err := model.SendHTTPRequestJSON(model.HTTPRequestOptions[usagePayload, any]{
656-
Context: ctx,
657-
Endpoint: model.Endpoint{
658-
Token: s.config.Token,
659-
APIEndpoint: s.config.APIEndpoint,
660-
},
661-
Method: "POST",
662-
Path: "/api/v1/codex-usage",
663-
Payload: payload,
664-
Timeout: 5 * time.Second,
665-
})
666-
if err != nil {
667-
slog.Warn("Failed to send codex usage to server", slog.Any("err", err))
668-
}
669-
}
670-
671-
// GetCachedCodexRateLimit returns a copy of the cached Codex rate limit data, or nil if not available.
672-
func (s *CCInfoTimerService) GetCachedCodexRateLimit() *CodexRateLimitData {
673-
s.codexRateLimitCache.mu.RLock()
674-
defer s.codexRateLimitCache.mu.RUnlock()
675-
676-
if s.codexRateLimitCache.usage == nil {
677-
return nil
678-
}
679-
680-
// Return a copy
681-
copy := *s.codexRateLimitCache.usage
682-
windowsCopy := make([]CodexRateLimitWindow, len(copy.Windows))
683-
for i, w := range copy.Windows {
684-
windowsCopy[i] = w
685-
}
686-
copy.Windows = windowsCopy
687-
return &copy
688-
}
689-
690-
// GetCachedCodexRateLimitError returns the last error from Codex rate limit fetching, or empty string if none.
691-
func (s *CCInfoTimerService) GetCachedCodexRateLimitError() string {
692-
s.codexRateLimitCache.mu.RLock()
693-
defer s.codexRateLimitCache.mu.RUnlock()
694-
return s.codexRateLimitCache.lastError
695-
}
696-
697554
// shortenAPIError converts an Anthropic usage API error into a short string for statusline display.
698555
func shortenAPIError(err error) string {
699556
msg := err.Error()

daemon/codex_ratelimit.go

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package daemon
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"net/http"
89
"os"
@@ -13,6 +14,19 @@ import (
1314

1415
const codexUsageCacheTTL = 10 * time.Minute
1516

17+
var (
18+
loadCodexAuthFunc = loadCodexAuth
19+
fetchCodexUsageFunc = fetchCodexUsage
20+
codexPathExistsFunc = codexPathExists
21+
)
22+
23+
var (
24+
errCodexDirMissing = errors.New("codex directory missing")
25+
errCodexAuthFileMissing = errors.New("codex auth file missing")
26+
errCodexAuthInvalid = errors.New("codex auth invalid")
27+
errCodexTokenInvalid = errors.New("codex token invalid")
28+
)
29+
1630
// CodexRateLimitData holds the parsed rate limit data from the Codex API
1731
type CodexRateLimitData struct {
1832
Plan string
@@ -58,15 +72,79 @@ type codexIDTokenClaims struct {
5872
AccountID string `json:"accountId"`
5973
}
6074

75+
func codexConfigDirPath() (string, error) {
76+
homeDir, err := os.UserHomeDir()
77+
if err != nil {
78+
return "", fmt.Errorf("failed to get home directory: %w", err)
79+
}
80+
81+
return filepath.Join(homeDir, ".codex"), nil
82+
}
83+
84+
func codexAuthFilePath() (string, error) {
85+
dir, err := codexConfigDirPath()
86+
if err != nil {
87+
return "", err
88+
}
89+
90+
return filepath.Join(dir, "auth.json"), nil
91+
}
92+
93+
func codexPathExists(path string) (bool, error) {
94+
_, err := os.Stat(path)
95+
if err == nil {
96+
return true, nil
97+
}
98+
if errors.Is(err, os.ErrNotExist) {
99+
return false, nil
100+
}
101+
return false, err
102+
}
103+
104+
func codexInstallationStatus() (bool, error) {
105+
dirPath, err := codexConfigDirPath()
106+
if err != nil {
107+
return false, err
108+
}
109+
exists, err := codexPathExistsFunc(dirPath)
110+
if err != nil {
111+
return false, err
112+
}
113+
if !exists {
114+
return false, errCodexDirMissing
115+
}
116+
117+
authPath, err := codexAuthFilePath()
118+
if err != nil {
119+
return false, err
120+
}
121+
exists, err = codexPathExistsFunc(authPath)
122+
if err != nil {
123+
return false, err
124+
}
125+
if !exists {
126+
return false, errCodexAuthFileMissing
127+
}
128+
129+
return true, nil
130+
}
131+
132+
func CodexInstallationStatus() (bool, error) {
133+
return codexInstallationStatus()
134+
}
135+
61136
// loadCodexAuth reads the Codex authentication data from ~/.codex/auth.json.
62137
func loadCodexAuth() (*codexAuthData, error) {
63-
homeDir, err := os.UserHomeDir()
138+
authPath, err := codexAuthFilePath()
64139
if err != nil {
65-
return nil, fmt.Errorf("failed to get home directory: %w", err)
140+
return nil, err
66141
}
67142

68-
data, err := os.ReadFile(filepath.Join(homeDir, ".codex", "auth.json"))
143+
data, err := os.ReadFile(authPath)
69144
if err != nil {
145+
if errors.Is(err, os.ErrNotExist) {
146+
return nil, errCodexAuthFileMissing
147+
}
70148
return nil, fmt.Errorf("codex auth file read failed: %w", err)
71149
}
72150

@@ -76,7 +154,7 @@ func loadCodexAuth() (*codexAuthData, error) {
76154
}
77155

78156
if auth.TokenData == nil || auth.TokenData.AccessToken == "" {
79-
return nil, fmt.Errorf("no access token found in codex auth")
157+
return nil, errCodexAuthInvalid
80158
}
81159

82160
accountID := ""
@@ -128,6 +206,9 @@ func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitD
128206
defer resp.Body.Close()
129207

130208
if resp.StatusCode != http.StatusOK {
209+
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
210+
return nil, errCodexTokenInvalid
211+
}
131212
return nil, fmt.Errorf("codex usage API returned status %d", resp.StatusCode)
132213
}
133214

@@ -167,3 +248,18 @@ func shortenCodexAPIError(err error) string {
167248

168249
return "network"
169250
}
251+
252+
func CodexSyncSkipReason(err error) (string, bool) {
253+
switch {
254+
case errors.Is(err, errCodexDirMissing):
255+
return "missing_codex_dir", true
256+
case errors.Is(err, errCodexAuthFileMissing):
257+
return "missing_auth_file", true
258+
case errors.Is(err, errCodexAuthInvalid):
259+
return "invalid_auth", true
260+
case errors.Is(err, errCodexTokenInvalid):
261+
return "invalid_auth_token", true
262+
default:
263+
return "", false
264+
}
265+
}

0 commit comments

Comments
 (0)