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 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/`.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "codex_limitless/cli"
7
+
8
+ exit CodexLimitless::CLI.new(ARGV).run
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodexLimitless
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codex_limitless/version"
4
+
5
+ module CodexLimitless
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "codex_limitless/limits"
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: []