@@ -55,6 +55,9 @@ type CCInfoTimerService struct {
5555 // Anthropic rate limit cache
5656 rateLimitCache * anthropicRateLimitCache
5757
58+ // Codex rate limit cache
59+ codexRateLimitCache * codexRateLimitCache
60+
5861 // User profile cache (permanent for daemon lifetime)
5962 userLogin string
6063 userLoginFetched bool
@@ -67,7 +70,8 @@ func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService {
6770 cache : make (map [CCInfoTimeRange ]CCInfoCache ),
6871 activeRanges : make (map [CCInfoTimeRange ]bool ),
6972 gitCache : make (map [string ]* GitCacheEntry ),
70- rateLimitCache : & anthropicRateLimitCache {},
73+ rateLimitCache : & anthropicRateLimitCache {},
74+ codexRateLimitCache : & codexRateLimitCache {},
7175 stopChan : make (chan struct {}),
7276 }
7377}
@@ -152,6 +156,11 @@ func (s *CCInfoTimerService) stopTimer() {
152156 s .rateLimitCache .fetchedAt = time.Time {}
153157 s .rateLimitCache .lastAttemptAt = time.Time {}
154158 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 ()
155164
156165 slog .Info ("CC info timer stopped due to inactivity" )
157166}
@@ -171,6 +180,7 @@ func (s *CCInfoTimerService) timerLoop() {
171180 ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
172181 defer cancel ()
173182 s .fetchRateLimit (ctx )
183+ s .fetchCodexRateLimit (ctx )
174184 }()
175185 go s .fetchUserProfile (context .Background ())
176186
@@ -194,6 +204,7 @@ func (s *CCInfoTimerService) timerLoop() {
194204 ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
195205 defer cancel ()
196206 s .fetchRateLimit (ctx )
207+ s .fetchCodexRateLimit (ctx )
197208 }()
198209
199210 case <- s .stopChan :
@@ -551,6 +562,138 @@ func (s *CCInfoTimerService) GetCachedRateLimitError() string {
551562 return s .rateLimitCache .lastError
552563}
553564
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+
554697// shortenAPIError converts an Anthropic usage API error into a short string for statusline display.
555698func shortenAPIError (err error ) string {
556699 msg := err .Error ()
0 commit comments