codex_limitless 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +159 -0
- data/exe/codex-limitless +8 -0
- data/lib/codex_limitless/cli.rb +236 -0
- data/lib/codex_limitless/limits.rb +153 -0
- data/lib/codex_limitless/version.rb +5 -0
- data/lib/codex_limitless.rb +9 -0
- metadata +44 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 212a5c42dbeafcb4dd6e701576a44b7a16a41fa0a60807dae657d0831b9309e5
|
|
4
|
+
data.tar.gz: ac5e72f36226a1f4902fbe8eb50654a6315a5254ee0774bcd2413992bee7d730
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b9e3ed810b9e26179ce4b9b55d878a75ee9602868ab6ca58120d8694082b9f1b2d4fa98119a5f5640d02f303837af7ef202b3909a6d112ac1b184136621a535d
|
|
7
|
+
data.tar.gz: 6f7fb9fe169163eaeec02cbd15a51c6211e4ab75638b54ecc97ad325af7f263b3307daddbb6b62df7de9c8ccad95dd29d26de277cfd7fbb72a170112d55d25dc
|
data/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# codex_limitless
|
|
2
|
+
|
|
3
|
+
`codex_limitless` provides the `codex-limitless` command for inspecting Codex usage limits from the Codex CLI app-server API.
|
|
4
|
+
|
|
5
|
+
It can print the current limit snapshot as JSON, wait until the five-hour usage window resets, or automatically wait only when the remaining five-hour percentage is low.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install from RubyGems:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
gem install codex_limitless
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
After installation, the `codex-limitless` executable should be available on your shell path:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
codex-limitless --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For development, build and install the gem from this checkout:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
gem build codex_limitless.gemspec
|
|
25
|
+
gem install ./codex_limitless-0.1.0.gem
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
You can also run the executable directly from the checkout:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
ruby exe/codex-limitless --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
codex-limitless -l
|
|
38
|
+
codex-limitless --limits
|
|
39
|
+
|
|
40
|
+
codex-limitless -w
|
|
41
|
+
codex-limitless --wait
|
|
42
|
+
|
|
43
|
+
codex-limitless -a
|
|
44
|
+
codex-limitless --auto
|
|
45
|
+
codex-limitless --auto --percentage 20
|
|
46
|
+
|
|
47
|
+
codex-limitless -h
|
|
48
|
+
codex-limitless --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Commands
|
|
52
|
+
|
|
53
|
+
`-l`, `--limits`
|
|
54
|
+
|
|
55
|
+
Fetch and print the current Codex limit summary as pretty JSON.
|
|
56
|
+
|
|
57
|
+
`-w`, `--wait`
|
|
58
|
+
|
|
59
|
+
Fetch the current Codex limit summary, read `five_hour.resets_at_local`, and poll once per second until the local clock is greater than or equal to that reset time.
|
|
60
|
+
|
|
61
|
+
`-a`, `--auto`
|
|
62
|
+
|
|
63
|
+
Fetch the current Codex limit summary and check `five_hour.remaining_percent`. If the remaining percentage is at or below the configured threshold, read `five_hour.resets_at_local` and poll once per second until the local clock is greater than or equal to that reset time. While waiting, re-fetch the limit summary once per minute and update the same status line with the latest five-hour remaining percentage. If the remaining percentage is above the threshold, exit without waiting.
|
|
64
|
+
|
|
65
|
+
`-h`, `--help`
|
|
66
|
+
|
|
67
|
+
Print the CLI help text.
|
|
68
|
+
|
|
69
|
+
`-v`, `--version`
|
|
70
|
+
|
|
71
|
+
Print the gem version.
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
codex-limitless --limits --limit-id codex
|
|
77
|
+
codex-limitless --wait --codex-bin /path/to/codex
|
|
78
|
+
codex-limitless --auto --percentage 15
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`--limit-id LIMIT_ID`
|
|
82
|
+
|
|
83
|
+
Inspect a specific Codex rate limit id. Defaults to `codex`.
|
|
84
|
+
|
|
85
|
+
`--codex-bin PATH`
|
|
86
|
+
|
|
87
|
+
Use a specific Codex CLI executable. Defaults to `codex`.
|
|
88
|
+
|
|
89
|
+
`-p`, `--percentage PERCENT`
|
|
90
|
+
|
|
91
|
+
Remaining percentage threshold for `--auto`. Defaults to `15`.
|
|
92
|
+
|
|
93
|
+
## AGENTS.md
|
|
94
|
+
|
|
95
|
+
Add this instruction to the user's `AGENTS.md` file to help prevent long Codex goals from hitting the five-hour limit. At the beginning of every round, `codex-limitless -a` checks the five-hour remaining percentage and waits for the reset only when the remaining percentage is at or below the configured threshold. If it waits, it refreshes the displayed percentage every minute on the same status line.
|
|
96
|
+
|
|
97
|
+
```md
|
|
98
|
+
- At the beginning of every round, run: "codex-limitless -a". Run it directly with no timeout wrapper. Wait for the command to finish before continuing the round. Ignore output.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Environment
|
|
102
|
+
|
|
103
|
+
`CODEX_BIN`
|
|
104
|
+
|
|
105
|
+
Default Codex CLI executable path when `--codex-bin` is not provided.
|
|
106
|
+
|
|
107
|
+
`CODEX_LIMIT_ID`
|
|
108
|
+
|
|
109
|
+
Default limit id when `--limit-id` is not provided.
|
|
110
|
+
|
|
111
|
+
`CODEX_USAGE_TIMEOUT`
|
|
112
|
+
|
|
113
|
+
Timeout, in seconds, for Codex app-server requests. Defaults to `30`.
|
|
114
|
+
|
|
115
|
+
## JSON Output
|
|
116
|
+
|
|
117
|
+
`--limits` prints an object with the selected limit id, plan details, five-hour window details, weekly window details, and any reset credits returned by Codex.
|
|
118
|
+
|
|
119
|
+
Example shape:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"limit_id": "codex",
|
|
124
|
+
"limit_name": "Codex",
|
|
125
|
+
"plan_type": "example",
|
|
126
|
+
"five_hour": {
|
|
127
|
+
"window_duration_mins": 300,
|
|
128
|
+
"used_percent": 80,
|
|
129
|
+
"remaining_percent": 20,
|
|
130
|
+
"resets_at": 1781978400,
|
|
131
|
+
"resets_at_local": "2026-06-20 12:00:00 PM CDT",
|
|
132
|
+
"resets_at_iso8601": "2026-06-20T12:00:00-05:00"
|
|
133
|
+
},
|
|
134
|
+
"weekly": {
|
|
135
|
+
"window_duration_mins": 10080,
|
|
136
|
+
"used_percent": 10,
|
|
137
|
+
"remaining_percent": 90,
|
|
138
|
+
"resets_at": 1782324000,
|
|
139
|
+
"resets_at_local": "2026-06-24 12:00:00 PM CDT",
|
|
140
|
+
"resets_at_iso8601": "2026-06-24T12:00:00-05:00"
|
|
141
|
+
},
|
|
142
|
+
"rate_limit_reset_credits": null
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
Run basic checks:
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
rake test
|
|
152
|
+
ruby -c lib/codex_limitless.rb
|
|
153
|
+
ruby -c lib/codex_limitless/limits.rb
|
|
154
|
+
ruby -c lib/codex_limitless/cli.rb
|
|
155
|
+
ruby -c exe/codex-limitless
|
|
156
|
+
gem build codex_limitless.gemspec
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`rake test` runs the Minitest suite and enforces 100% line coverage for the gem files in `lib/`.
|
data/exe/codex-limitless
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "../codex_limitless"
|
|
8
|
+
|
|
9
|
+
module CodexLimitless
|
|
10
|
+
class CLI
|
|
11
|
+
DEFAULT_PERCENTAGE = 15
|
|
12
|
+
AUTO_REFRESH_SECONDS = 60
|
|
13
|
+
|
|
14
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
15
|
+
@argv = argv.dup
|
|
16
|
+
@out = out
|
|
17
|
+
@err = err
|
|
18
|
+
@status_line_width = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
options = {
|
|
23
|
+
codex_bin: ENV.fetch("CODEX_BIN", "codex"),
|
|
24
|
+
limit_id: ENV.fetch("CODEX_LIMIT_ID", "codex"),
|
|
25
|
+
percentage: DEFAULT_PERCENTAGE,
|
|
26
|
+
command: nil
|
|
27
|
+
}
|
|
28
|
+
parser = option_parser(options)
|
|
29
|
+
parser.parse!(@argv)
|
|
30
|
+
|
|
31
|
+
case options[:command]
|
|
32
|
+
when :limits
|
|
33
|
+
print_limits(options)
|
|
34
|
+
when :wait
|
|
35
|
+
wait_for_five_hour_reset(options)
|
|
36
|
+
when :auto
|
|
37
|
+
auto_wait_for_five_hour_reset(options)
|
|
38
|
+
when :version
|
|
39
|
+
@out.puts VERSION
|
|
40
|
+
else
|
|
41
|
+
@out.puts parser
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
0
|
|
45
|
+
rescue OptionParser::ParseError => e
|
|
46
|
+
@err.puts "Error: #{e.message}"
|
|
47
|
+
@err.puts
|
|
48
|
+
@err.puts option_parser(default_options)
|
|
49
|
+
1
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
@err.puts "Error: #{e.message}"
|
|
52
|
+
1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def default_options
|
|
58
|
+
{
|
|
59
|
+
codex_bin: ENV.fetch("CODEX_BIN", "codex"),
|
|
60
|
+
limit_id: ENV.fetch("CODEX_LIMIT_ID", "codex"),
|
|
61
|
+
percentage: DEFAULT_PERCENTAGE,
|
|
62
|
+
command: nil
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def option_parser(options)
|
|
67
|
+
OptionParser.new do |parser|
|
|
68
|
+
parser.banner = "Usage: codex-limitless [options]"
|
|
69
|
+
parser.separator ""
|
|
70
|
+
parser.separator "Commands:"
|
|
71
|
+
|
|
72
|
+
parser.on("-l", "--limits", "Fetch and print Codex limit information as JSON.") do
|
|
73
|
+
set_command(options, :limits)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
parser.on("-w", "--wait", "Wait until five_hour.resets_at_local from the fetched JSON.") do
|
|
77
|
+
set_command(options, :wait)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
parser.on("-a", "--auto", "Wait when five_hour.remaining_percent is at or below --percentage; refresh every minute.") do
|
|
81
|
+
set_command(options, :auto)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
parser.separator ""
|
|
85
|
+
parser.separator "Options:"
|
|
86
|
+
|
|
87
|
+
parser.on("--limit-id LIMIT_ID", "Codex rate limit id to inspect (default: #{options[:limit_id]}).") do |limit_id|
|
|
88
|
+
options[:limit_id] = limit_id
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
parser.on("--codex-bin PATH", "Codex CLI path (default: #{options[:codex_bin]}).") do |codex_bin|
|
|
92
|
+
options[:codex_bin] = codex_bin
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
parser.on("-p", "--percentage PERCENT", Integer, "Auto wait threshold percentage (default: #{options[:percentage]}).") do |percentage|
|
|
96
|
+
raise OptionParser::ParseError, "percentage must be between 0 and 100" unless percentage.between?(0, 100)
|
|
97
|
+
|
|
98
|
+
options[:percentage] = percentage
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
parser.on("-v", "--version", "Print the codex_limitless version.") do
|
|
102
|
+
set_command(options, :version)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
parser.on("-h", "--help", "Show this help message.") do
|
|
106
|
+
set_command(options, :help)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def set_command(options, command)
|
|
112
|
+
return options[:command] = command if command == :help
|
|
113
|
+
return if options[:command] == :help
|
|
114
|
+
return options[:command] = command if options[:command].nil? || options[:command] == command
|
|
115
|
+
|
|
116
|
+
raise OptionParser::ParseError, "choose only one command"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def print_limits(options)
|
|
120
|
+
@out.puts JSON.pretty_generate(fetch_summary(options))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wait_for_five_hour_reset(options)
|
|
124
|
+
summary = fetch_summary(options)
|
|
125
|
+
wait_until_five_hour_reset(summary)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def auto_wait_for_five_hour_reset(options)
|
|
129
|
+
summary = fetch_summary(options)
|
|
130
|
+
remaining_percent = five_hour_remaining_percent(summary)
|
|
131
|
+
|
|
132
|
+
threshold = options[:percentage]
|
|
133
|
+
if remaining_percent.to_i > threshold
|
|
134
|
+
@out.puts "Five-hour remaining is #{remaining_percent}%, above #{threshold}%; not waiting."
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
current_remaining_percent = remaining_percent
|
|
139
|
+
status_message = proc do |remaining, reset_text|
|
|
140
|
+
"Five-hour remaining is #{current_remaining_percent}%, reset at #{reset_text} " \
|
|
141
|
+
"(#{format_duration(remaining)} remaining)"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
wait_until_five_hour_reset(
|
|
145
|
+
summary,
|
|
146
|
+
refresh_seconds: AUTO_REFRESH_SECONDS,
|
|
147
|
+
status_message: status_message
|
|
148
|
+
) do
|
|
149
|
+
refreshed = fetch_summary(options)
|
|
150
|
+
current_remaining_percent = five_hour_remaining_percent(refreshed)
|
|
151
|
+
refreshed
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def wait_until_five_hour_reset(summary, refresh_seconds: nil, status_message: nil, &refresh_summary)
|
|
156
|
+
reset_text = five_hour_reset_text(summary)
|
|
157
|
+
reset_at = Time.parse(reset_text)
|
|
158
|
+
next_refresh_at = refresh_summary ? Time.now + refresh_seconds : nil
|
|
159
|
+
@out.puts "Waiting until five-hour reset at #{reset_text}." unless status_message
|
|
160
|
+
print_status_line(status_message.call(seconds_until(reset_at), reset_text)) if status_message
|
|
161
|
+
|
|
162
|
+
until Time.now >= reset_at
|
|
163
|
+
if next_refresh_at && Time.now >= next_refresh_at
|
|
164
|
+
summary = refresh_summary.call
|
|
165
|
+
reset_text = five_hour_reset_text(summary)
|
|
166
|
+
reset_at = Time.parse(reset_text)
|
|
167
|
+
next_refresh_at = Time.now + refresh_seconds
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
remaining = seconds_until(reset_at)
|
|
171
|
+
if status_message
|
|
172
|
+
print_status_line(status_message.call(remaining, reset_text))
|
|
173
|
+
elsif tty?
|
|
174
|
+
print_wait_status(remaining)
|
|
175
|
+
end
|
|
176
|
+
break if Time.now >= reset_at
|
|
177
|
+
|
|
178
|
+
sleep 1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
status_message ? finish_status_line : (@out.puts if tty?)
|
|
182
|
+
@out.puts "Five-hour reset reached at #{Time.now.strftime("%Y-%m-%d %I:%M:%S %p %Z")}."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def five_hour_remaining_percent(summary)
|
|
186
|
+
remaining_percent = summary.dig("five_hour", "remaining_percent")
|
|
187
|
+
raise Error, "five_hour.remaining_percent is missing from the fetched limit JSON" if remaining_percent.nil?
|
|
188
|
+
|
|
189
|
+
remaining_percent
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def five_hour_reset_text(summary)
|
|
193
|
+
reset_text = summary.dig("five_hour", "resets_at_local")
|
|
194
|
+
raise Error, "five_hour.resets_at_local is missing from the fetched limit JSON" if reset_text.nil? || reset_text.empty?
|
|
195
|
+
|
|
196
|
+
reset_text
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fetch_summary(options)
|
|
200
|
+
Limits.summary(codex_bin: options[:codex_bin], limit_id: options[:limit_id])
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def print_wait_status(remaining)
|
|
204
|
+
@out.print "\r#{format_duration(remaining)} remaining"
|
|
205
|
+
@out.flush
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def print_status_line(message)
|
|
209
|
+
padding_width = [@status_line_width - message.length, 0].max
|
|
210
|
+
prefix = @status_line_width.zero? ? "" : "\r"
|
|
211
|
+
@out.print "#{prefix}#{message}#{" " * padding_width}"
|
|
212
|
+
@out.flush
|
|
213
|
+
@status_line_width = message.length
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def finish_status_line
|
|
217
|
+
@out.puts
|
|
218
|
+
@status_line_width = 0
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def seconds_until(time)
|
|
222
|
+
[(time - Time.now).ceil, 0].max
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def format_duration(seconds)
|
|
226
|
+
hours = seconds / 3600
|
|
227
|
+
minutes = (seconds % 3600) / 60
|
|
228
|
+
secs = seconds % 60
|
|
229
|
+
format("%02d:%02d:%02d", hours, minutes, secs)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def tty?
|
|
233
|
+
@out.respond_to?(:tty?) && @out.tty?
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "time"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module CodexLimitless
|
|
11
|
+
unless const_defined?(:Error, false)
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class AppServerClient
|
|
16
|
+
REQUEST_TIMEOUT_SECONDS = Integer(ENV.fetch("CODEX_USAGE_TIMEOUT", "30"))
|
|
17
|
+
|
|
18
|
+
def initialize(codex_bin)
|
|
19
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(codex_bin, "app-server", "--stdio")
|
|
20
|
+
@stderr_reader = Thread.new { @stderr.read }
|
|
21
|
+
@next_id = 0
|
|
22
|
+
rescue Errno::ENOENT
|
|
23
|
+
raise Error, "Could not find `#{codex_bin}`. Set CODEX_BIN to the Codex CLI path."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def request(method, params: nil)
|
|
27
|
+
id = next_request_id
|
|
28
|
+
payload = { "jsonrpc" => "2.0", "id" => id, "method" => method }
|
|
29
|
+
payload["params"] = params unless params.nil?
|
|
30
|
+
|
|
31
|
+
@stdin.puts(JSON.generate(payload))
|
|
32
|
+
@stdin.flush
|
|
33
|
+
|
|
34
|
+
read_response(id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def close
|
|
38
|
+
@stdin.close unless @stdin.closed?
|
|
39
|
+
|
|
40
|
+
Timeout.timeout(2) { @wait_thread.value }
|
|
41
|
+
rescue Timeout::Error
|
|
42
|
+
Process.kill("TERM", @wait_thread.pid)
|
|
43
|
+
@wait_thread.value
|
|
44
|
+
ensure
|
|
45
|
+
@stderr_reader&.join(0.2)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def next_request_id
|
|
51
|
+
@next_id += 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_response(id)
|
|
55
|
+
deadline = Time.now + REQUEST_TIMEOUT_SECONDS
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
line = read_line(deadline)
|
|
59
|
+
message = JSON.parse(line)
|
|
60
|
+
next unless message["id"] == id
|
|
61
|
+
|
|
62
|
+
raise response_error(message) if message["error"]
|
|
63
|
+
|
|
64
|
+
return message["result"]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_line(deadline)
|
|
69
|
+
remaining = deadline - Time.now
|
|
70
|
+
raise Timeout::Error, "Timed out waiting for Codex app-server" if remaining <= 0
|
|
71
|
+
|
|
72
|
+
line = Timeout.timeout(remaining) { @stdout.gets }
|
|
73
|
+
return line if line
|
|
74
|
+
|
|
75
|
+
stderr = @stderr_reader&.value.to_s.strip
|
|
76
|
+
details = stderr.empty? ? "" : ": #{stderr}"
|
|
77
|
+
raise Error, "Codex app-server exited before responding#{details}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def response_error(message)
|
|
81
|
+
error = message["error"]
|
|
82
|
+
code = error["code"] || "unknown"
|
|
83
|
+
text = error["message"] || error.inspect
|
|
84
|
+
Error.new("Codex app-server request failed (#{code}): #{text}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
module Limits
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
def summary(codex_bin:, limit_id:)
|
|
92
|
+
rate_limits = fetch_rate_limits(codex_bin)
|
|
93
|
+
snapshot = select_limit_snapshot(rate_limits, limit_id)
|
|
94
|
+
raise Error, "No rate limit snapshot found for limit id `#{limit_id}`" unless snapshot
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
"limit_id" => snapshot["limitId"] || limit_id,
|
|
98
|
+
"limit_name" => snapshot["limitName"],
|
|
99
|
+
"plan_type" => snapshot["planType"],
|
|
100
|
+
"five_hour" => window_summary(select_window(snapshot, 300, "primary")),
|
|
101
|
+
"weekly" => window_summary(select_window(snapshot, 10_080, "secondary")),
|
|
102
|
+
"rate_limit_reset_credits" => rate_limits["rateLimitResetCredits"]
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def fetch_rate_limits(codex_bin)
|
|
107
|
+
client = AppServerClient.new(codex_bin)
|
|
108
|
+
client.request(
|
|
109
|
+
"initialize",
|
|
110
|
+
params: {
|
|
111
|
+
"clientInfo" => {
|
|
112
|
+
"name" => "codex-limitless",
|
|
113
|
+
"title" => nil,
|
|
114
|
+
"version" => VERSION
|
|
115
|
+
},
|
|
116
|
+
"capabilities" => {
|
|
117
|
+
"experimentalApi" => true,
|
|
118
|
+
"requestAttestation" => false,
|
|
119
|
+
"optOutNotificationMethods" => []
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
client.request("account/rateLimits/read")
|
|
124
|
+
ensure
|
|
125
|
+
client&.close
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def select_limit_snapshot(rate_limits, limit_id)
|
|
129
|
+
by_id = rate_limits["rateLimitsByLimitId"] || {}
|
|
130
|
+
by_id[limit_id] || rate_limits["rateLimits"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def select_window(snapshot, duration_mins, fallback_key)
|
|
134
|
+
windows = [snapshot["primary"], snapshot["secondary"]].compact
|
|
135
|
+
windows.find { |window| window["windowDurationMins"].to_i == duration_mins } || snapshot[fallback_key]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def window_summary(window)
|
|
139
|
+
used_percent = window&.fetch("usedPercent", nil)
|
|
140
|
+
remaining_percent = used_percent.nil? ? nil : [[100 - used_percent.to_i, 0].max, 100].min
|
|
141
|
+
resets_at = window&.fetch("resetsAt", nil)
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
"window_duration_mins" => window&.fetch("windowDurationMins", nil),
|
|
145
|
+
"used_percent" => used_percent,
|
|
146
|
+
"remaining_percent" => remaining_percent,
|
|
147
|
+
"resets_at" => resets_at,
|
|
148
|
+
"resets_at_local" => resets_at ? Time.at(resets_at).strftime("%Y-%m-%d %I:%M:%S %p %Z") : nil,
|
|
149
|
+
"resets_at_iso8601" => resets_at ? Time.at(resets_at).iso8601 : nil
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: codex_limitless
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Adam
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-20 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A CLI gem that reads Codex app-server rate limit data and waits for five-hour
|
|
13
|
+
reset times.
|
|
14
|
+
executables:
|
|
15
|
+
- codex-limitless
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- README.md
|
|
20
|
+
- exe/codex-limitless
|
|
21
|
+
- lib/codex_limitless.rb
|
|
22
|
+
- lib/codex_limitless/cli.rb
|
|
23
|
+
- lib/codex_limitless/limits.rb
|
|
24
|
+
- lib/codex_limitless/version.rb
|
|
25
|
+
licenses: []
|
|
26
|
+
metadata: {}
|
|
27
|
+
rdoc_options: []
|
|
28
|
+
require_paths:
|
|
29
|
+
- lib
|
|
30
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
31
|
+
requirements:
|
|
32
|
+
- - ">="
|
|
33
|
+
- !ruby/object:Gem::Version
|
|
34
|
+
version: '3.1'
|
|
35
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
requirements: []
|
|
41
|
+
rubygems_version: 3.6.2
|
|
42
|
+
specification_version: 4
|
|
43
|
+
summary: Inspect Codex usage limits and wait for reset windows.
|
|
44
|
+
test_files: []
|