autolimit 0.2.0 → 0.3.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.
- package/README.md +67 -7
- package/lib/autolimit.py +305 -74
- package/package.json +4 -3
- package/scripts/postinstall.js +25 -3
package/README.md
CHANGED
|
@@ -2,15 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
`autolimit` is a terminal-agnostic PTY wrapper for interactive command-line runners.
|
|
4
4
|
|
|
5
|
-
It
|
|
5
|
+
It understands Claude Code style session-limit messages:
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
You've hit your session limit · resets 7:40pm (Asia/Seoul)
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
and OpenAI Codex CLI usage-limit messages:
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
```text
|
|
14
|
+
You've hit your usage limit. Try again at 7:30 PM.
|
|
15
|
+
You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Jul 8th, 2026 10:59 AM.
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
When a configured limit message appears, `autolimit` parses the reset time. After that reset time plus a small grace period, it automatically sends the contents of `~/.config/autolimit/message.txt` to the still-running interactive session. If the same limit message is still on screen after the send (the session did not actually unblock), it retries a few times with backoff (60s / 120s / 240s).
|
|
19
|
+
|
|
20
|
+
Recognized reset formats:
|
|
21
|
+
|
|
22
|
+
- time-of-day: `resets 7:40pm`, `resets 19:40`, `Try again at 7:30 PM.`
|
|
23
|
+
- dated: `resets Jul 8 at 10:59am`, `try again at Jul 8th, 2026 10:59 AM.`
|
|
24
|
+
- relative (older codex builds): `try again in 4 days 23 hours`
|
|
25
|
+
|
|
26
|
+
Codex prints reset times with the machine's local clock and no timezone name, so the shipped codex patterns carry `"timezone": "local"`. Codex messages without a reset time (`Try again later.`) are ignored on purpose: there is nothing to schedule.
|
|
27
|
+
|
|
28
|
+
If the wrapped process exits after printing the limit message — or exits right after an auto-send, before the message could be processed — `autolimit` can run a runner-specific fallback command after the reset time. Built-in fallback profiles are included for `claude`, `cc`, and `codex`.
|
|
14
29
|
|
|
15
30
|
## Installation
|
|
16
31
|
|
|
@@ -71,6 +86,12 @@ Wrap Claude Code directly:
|
|
|
71
86
|
autolimit --claude
|
|
72
87
|
```
|
|
73
88
|
|
|
89
|
+
Wrap Codex:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
autolimit --codex
|
|
93
|
+
```
|
|
94
|
+
|
|
74
95
|
Wrap any command:
|
|
75
96
|
|
|
76
97
|
```bash
|
|
@@ -179,6 +200,9 @@ If the process exits after printing the limit message, `autolimit` uses a fallba
|
|
|
179
200
|
},
|
|
180
201
|
"cc": {
|
|
181
202
|
"fallback": ["{runner}", "{args}", "-c", "-p", "{message}"]
|
|
203
|
+
},
|
|
204
|
+
"codex": {
|
|
205
|
+
"fallback": ["{runner}", "exec", "resume", "--last", "{message}"]
|
|
182
206
|
}
|
|
183
207
|
}
|
|
184
208
|
}
|
|
@@ -190,6 +214,14 @@ So `autolimit --cc --model sonnet` falls back to roughly:
|
|
|
190
214
|
cc --model sonnet -c -p '<message>'
|
|
191
215
|
```
|
|
192
216
|
|
|
217
|
+
and `autolimit --codex` falls back to:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
codex exec resume --last '<message>'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The codex fallback intentionally does not re-insert the original arguments: flags meant for the interactive TUI are not all valid for `codex exec`. Adjust the profile if your codex invocation needs them.
|
|
224
|
+
|
|
193
225
|
The actual fallback is run through the same mode as the original invocation. In default shell mode, aliases and functions are respected.
|
|
194
226
|
|
|
195
227
|
The fallback also covers two exit-time edge cases:
|
|
@@ -225,7 +257,7 @@ Example:
|
|
|
225
257
|
|
|
226
258
|
```json
|
|
227
259
|
{
|
|
228
|
-
"version":
|
|
260
|
+
"version": 3,
|
|
229
261
|
"message_file": "/Users/you/.config/autolimit/message.txt",
|
|
230
262
|
"log_file": "/Users/you/.config/autolimit/limit-watch.log",
|
|
231
263
|
"default_timezone": "Asia/Seoul",
|
|
@@ -240,6 +272,21 @@ Example:
|
|
|
240
272
|
{
|
|
241
273
|
"name": "Claude-style dated limit",
|
|
242
274
|
"regex": "You[\\u2019']?ve\\s+hit\\s+your(?:\\s+[\\w-]+)?\\s+limit.{0,120}?resets\\s+(?P<month>[A-Za-z]{3,9})\\.?\\s+(?P<day>\\d{1,2})(?:st|nd|rd|th)?,?\\s*(?:at\\s+)?(?P<hour>\\d{1,2})(?::(?P<minute>\\d{2}))?\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)?\\s*\\((?P<tzname>[^)]{1,64})\\)"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"name": "Codex-style usage limit",
|
|
278
|
+
"regex": "You[\\u2019']?ve\\s+hit\\s+your\\s+usage\\s+limit.{0,200}?try\\s+again\\s+at\\s+(?P<hour>\\d{1,2}):(?P<minute>\\d{2})\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)",
|
|
279
|
+
"timezone": "local"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"name": "Codex-style dated usage limit",
|
|
283
|
+
"regex": "You[\\u2019']?ve\\s+hit\\s+your\\s+usage\\s+limit.{0,200}?try\\s+again\\s+at\\s+(?P<month>[A-Za-z]{3,9})\\.?\\s+(?P<day>\\d{1,2})(?:st|nd|rd|th)?,?\\s*(?:(?P<year>\\d{4})\\s+)?(?P<hour>\\d{1,2}):(?P<minute>\\d{2})\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)",
|
|
284
|
+
"timezone": "local"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"name": "Codex-style relative usage limit",
|
|
288
|
+
"regex": "You[\\u2019']?ve\\s+hit\\s+your\\s+usage\\s+limit.{0,200}?try\\s+again\\s+in\\s+(?=\\d+\\s*(?:d(?:ays?)?|h(?:(?:ou)?rs?)?|m(?:in(?:ute)?s?)?|s(?:ec(?:ond)?s?))\\b)(?:(?P<rel_days>\\d+)\\s*d(?:ays?)?\\b)?[,\\s]*(?:(?P<rel_hours>\\d+)\\s*h(?:(?:ou)?rs?)?\\b)?[,\\s]*(?:(?P<rel_minutes>\\d+)\\s*m(?:in(?:ute)?s?)?\\b)?[,\\s]*(?:(?P<rel_seconds>\\d+)\\s*s(?:ec(?:ond)?s?)?\\b)?",
|
|
289
|
+
"max_ahead_hours": 192
|
|
243
290
|
}
|
|
244
291
|
],
|
|
245
292
|
"runners": {
|
|
@@ -248,6 +295,9 @@ Example:
|
|
|
248
295
|
},
|
|
249
296
|
"cc": {
|
|
250
297
|
"fallback": ["{runner}", "{args}", "-c", "-p", "{message}"]
|
|
298
|
+
},
|
|
299
|
+
"codex": {
|
|
300
|
+
"fallback": ["{runner}", "exec", "resume", "--last", "{message}"]
|
|
251
301
|
}
|
|
252
302
|
}
|
|
253
303
|
}
|
|
@@ -259,7 +309,12 @@ Settings:
|
|
|
259
309
|
- `max_schedule_ahead_hours`: a parsed reset time further ahead than this is treated as stale scrollback text and ignored (date-carrying patterns are allowed up to 8 days). This prevents an already-handled message from re-scheduling a next-day send.
|
|
260
310
|
- `max_fallback_wait_hours`: upper bound on how long the post-exit fallback will wait for a reset.
|
|
261
311
|
|
|
262
|
-
|
|
312
|
+
Per-pattern settings:
|
|
313
|
+
|
|
314
|
+
- `timezone`: timezone assumed when the message itself does not name one. Accepts an IANA name, an abbreviation, an offset, or the special value `local` (this machine's timezone). Used by the codex patterns because codex prints local wall-clock times.
|
|
315
|
+
- `max_ahead_hours`: raises the stale ceiling for this pattern (used by the relative codex pattern so weekly limits up to 8 days out can be scheduled).
|
|
316
|
+
|
|
317
|
+
A `config.json` from an earlier release is upgraded automatically on the next run: the v0.1 default pattern is replaced, configs that still carry the shipped Claude patterns gain the codex patterns, and the shipped Codex relative pattern is updated when needed. Custom patterns are left untouched.
|
|
263
318
|
|
|
264
319
|
### Custom runner fallback
|
|
265
320
|
|
|
@@ -286,13 +341,18 @@ For commands with complex spacing, prefer JSON array templates instead of string
|
|
|
286
341
|
|
|
287
342
|
### Custom limit patterns
|
|
288
343
|
|
|
289
|
-
Patterns are Python regular expressions.
|
|
344
|
+
Patterns are Python regular expressions. For wall-clock reset times, a pattern must provide these captures:
|
|
290
345
|
|
|
291
346
|
- `hour`, required
|
|
292
347
|
- `minute`, optional
|
|
293
348
|
- `ampm`, optional; omit it to accept 24-hour clocks
|
|
294
|
-
- `tzname`, optional; an IANA name (`Asia/Seoul`), a common abbreviation (`KST`, `PST`, ...),
|
|
349
|
+
- `tzname`, optional; an IANA name (`Asia/Seoul`), a common abbreviation (`KST`, `PST`, ...), an offset (`UTC+9`), or `local`; unknown names fall back to `default_timezone` and are logged. When the message has no timezone at all, the pattern's `timezone` setting applies, then `default_timezone`.
|
|
295
350
|
- `month` / `day`, optional named groups for date-carrying messages such as `resets Jul 8 at 10:59am`
|
|
351
|
+
- `year`, optional named group for messages that state the year explicitly, such as codex's `try again at Jul 8th, 2026 10:59 AM.`
|
|
352
|
+
|
|
353
|
+
For relative reset times (`try again in 4 days 23 hours`), provide one or more of the named groups `rel_days`, `rel_hours`, `rel_minutes`, `rel_seconds` instead; the reset time is computed from the moment the message is seen.
|
|
354
|
+
|
|
355
|
+
Positional groups (1=hour, 2=minute, 3=ampm, 4=tzname) are still accepted, but only for patterns that use no named groups at all.
|
|
296
356
|
|
|
297
357
|
Named captures are recommended. Keep the gap between the anchor text and the time bounded (for example `.{0,120}?` instead of `.*?`) so unrelated output cannot bridge a match:
|
|
298
358
|
|
package/lib/autolimit.py
CHANGED
|
@@ -34,7 +34,11 @@ try:
|
|
|
34
34
|
except Exception: # pragma: no cover - Python < 3.9 guard
|
|
35
35
|
ZoneInfo = None # type: ignore
|
|
36
36
|
|
|
37
|
-
VERSION = "0.
|
|
37
|
+
VERSION = "0.3.0"
|
|
38
|
+
|
|
39
|
+
# Bumped when the shipped default patterns change; config files with an older
|
|
40
|
+
# version are upgraded in place by migrate_legacy_patterns().
|
|
41
|
+
CONFIG_VERSION = 3
|
|
38
42
|
|
|
39
43
|
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "autolimit"
|
|
40
44
|
CONFIG_DIR = Path(os.environ.get("AUTOLIMIT_CONFIG_DIR", str(DEFAULT_CONFIG_DIR))).expanduser()
|
|
@@ -117,24 +121,84 @@ DEFAULT_PATTERN_DATED = (
|
|
|
117
121
|
r"(?P<ampm>[aApP]\.?[mM]\.?)?\s*\((?P<tzname>[^)]{1,64})\)"
|
|
118
122
|
)
|
|
119
123
|
|
|
124
|
+
# Codex prints reset times in the machine's local time and never names the
|
|
125
|
+
# timezone (codex-rs protocol/src/error.rs). Same-day resets look like
|
|
126
|
+
# "You've hit your usage limit. Try again at 7:30 PM." and other days like
|
|
127
|
+
# "... or try again at Jul 8th, 2026 10:59 AM.". The gap allows the plan or
|
|
128
|
+
# promo copy between the two anchors ("Upgrade to Pro (...), visit ... or").
|
|
129
|
+
DEFAULT_PATTERN_CODEX = (
|
|
130
|
+
r"You[\u2019']?ve\s+hit\s+your\s+usage\s+limit.{0,200}?"
|
|
131
|
+
r"try\s+again\s+at\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<ampm>[aApP]\.?[mM]\.?)"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
DEFAULT_PATTERN_CODEX_DATED = (
|
|
135
|
+
r"You[\u2019']?ve\s+hit\s+your\s+usage\s+limit.{0,200}?"
|
|
136
|
+
r"try\s+again\s+at\s+(?P<month>[A-Za-z]{3,9})\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?,?\s*"
|
|
137
|
+
r"(?:(?P<year>\d{4})\s+)?"
|
|
138
|
+
r"(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<ampm>[aApP]\.?[mM]\.?)"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Older codex builds phrased the reset relatively: "try again in 4 days
|
|
142
|
+
# 23 hours". The reset time is computed from the moment the message is seen.
|
|
143
|
+
OLD_DEFAULT_PATTERN_CODEX_RELATIVE = (
|
|
144
|
+
r"You[\u2019']?ve\s+hit\s+your\s+usage\s+limit.{0,200}?"
|
|
145
|
+
r"try\s+again\s+in\s+(?=\d)"
|
|
146
|
+
r"(?:(?P<rel_days>\d+)\s*d(?:ays?)?)?[,\s]*"
|
|
147
|
+
r"(?:(?P<rel_hours>\d+)\s*h(?:(?:ou)?rs?)?)?[,\s]*"
|
|
148
|
+
r"(?:(?P<rel_minutes>\d+)\s*m(?:in(?:ute)?s?)?)?[,\s]*"
|
|
149
|
+
r"(?:(?P<rel_seconds>\d+)\s*s(?:ec(?:ond)?s?)?)?"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
DEFAULT_PATTERN_CODEX_RELATIVE = (
|
|
153
|
+
r"You[\u2019']?ve\s+hit\s+your\s+usage\s+limit.{0,200}?"
|
|
154
|
+
r"try\s+again\s+in\s+(?=\d+\s*(?:d(?:ays?)?|h(?:(?:ou)?rs?)?|m(?:in(?:ute)?s?)?|s(?:ec(?:ond)?s?))\b)"
|
|
155
|
+
r"(?:(?P<rel_days>\d+)\s*d(?:ays?)?\b)?[,\s]*"
|
|
156
|
+
r"(?:(?P<rel_hours>\d+)\s*h(?:(?:ou)?rs?)?\b)?[,\s]*"
|
|
157
|
+
r"(?:(?P<rel_minutes>\d+)\s*m(?:in(?:ute)?s?)?\b)?[,\s]*"
|
|
158
|
+
r"(?:(?P<rel_seconds>\d+)\s*s(?:ec(?:ond)?s?)?\b)?"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Optional per-pattern settings carried alongside the regex in config.json.
|
|
162
|
+
PATTERN_META_KEYS = ("timezone", "max_ahead_hours")
|
|
163
|
+
|
|
164
|
+
_CLAUDE_DEFAULT_PATTERNS: list[dict[str, Any]] = [
|
|
165
|
+
{
|
|
166
|
+
"name": "Claude-style session limit",
|
|
167
|
+
"regex": DEFAULT_PATTERN,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"name": "Claude-style dated limit",
|
|
171
|
+
"regex": DEFAULT_PATTERN_DATED,
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
_CODEX_DEFAULT_PATTERNS: list[dict[str, Any]] = [
|
|
176
|
+
{
|
|
177
|
+
"name": "Codex-style usage limit",
|
|
178
|
+
"regex": DEFAULT_PATTERN_CODEX,
|
|
179
|
+
"timezone": "local",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"name": "Codex-style dated usage limit",
|
|
183
|
+
"regex": DEFAULT_PATTERN_CODEX_DATED,
|
|
184
|
+
"timezone": "local",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"name": "Codex-style relative usage limit",
|
|
188
|
+
"regex": DEFAULT_PATTERN_CODEX_RELATIVE,
|
|
189
|
+
"max_ahead_hours": DATED_MAX_SCHEDULE_AHEAD_HOURS,
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
|
|
120
193
|
DEFAULT_CONFIG: dict[str, Any] = {
|
|
121
|
-
"version":
|
|
194
|
+
"version": CONFIG_VERSION,
|
|
122
195
|
"message_file": str(MESSAGE_FILE),
|
|
123
196
|
"log_file": str(LOG_FILE),
|
|
124
197
|
"default_timezone": DEFAULT_TZ_NAME,
|
|
125
198
|
"send_grace_seconds": DEFAULT_SEND_GRACE_SECONDS,
|
|
126
199
|
"max_schedule_ahead_hours": DEFAULT_MAX_SCHEDULE_AHEAD_HOURS,
|
|
127
200
|
"max_fallback_wait_hours": DEFAULT_MAX_FALLBACK_WAIT_HOURS,
|
|
128
|
-
"patterns":
|
|
129
|
-
{
|
|
130
|
-
"name": "Claude-style session limit",
|
|
131
|
-
"regex": DEFAULT_PATTERN,
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
"name": "Claude-style dated limit",
|
|
135
|
-
"regex": DEFAULT_PATTERN_DATED,
|
|
136
|
-
},
|
|
137
|
-
],
|
|
201
|
+
"patterns": _CLAUDE_DEFAULT_PATTERNS + _CODEX_DEFAULT_PATTERNS,
|
|
138
202
|
"runners": {
|
|
139
203
|
# Fallback is only used when the wrapped process exits after printing a
|
|
140
204
|
# limit message. For Claude Code style runners, `-c -p <message>` resumes
|
|
@@ -145,6 +209,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
145
209
|
"cc": {
|
|
146
210
|
"fallback": ["{runner}", "{args}", "-c", "-p", "{message}"],
|
|
147
211
|
},
|
|
212
|
+
# `codex exec resume --last <prompt>` resumes the most recent codex
|
|
213
|
+
# session non-interactively. Original TUI arguments are intentionally
|
|
214
|
+
# not re-inserted: they are not all valid for `codex exec`.
|
|
215
|
+
"codex": {
|
|
216
|
+
"fallback": ["{runner}", "exec", "resume", "--last", "{message}"],
|
|
217
|
+
},
|
|
148
218
|
},
|
|
149
219
|
}
|
|
150
220
|
|
|
@@ -244,22 +314,69 @@ def _normalized_regex(value: Any) -> str:
|
|
|
244
314
|
return str(value).replace("’", "\\u2019")
|
|
245
315
|
|
|
246
316
|
|
|
317
|
+
def _pattern_regex(item: Any) -> Optional[str]:
|
|
318
|
+
regex = item.get("regex") if isinstance(item, dict) else item
|
|
319
|
+
return regex if isinstance(regex, str) and regex.strip() else None
|
|
320
|
+
|
|
321
|
+
|
|
247
322
|
def migrate_legacy_patterns(loaded: dict[str, Any]) -> bool:
|
|
248
|
-
patterns
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
323
|
+
"""Upgrade shipped default patterns from earlier releases in place.
|
|
324
|
+
|
|
325
|
+
The v0.1 default pattern is replaced with the full current default set.
|
|
326
|
+
Older configs that still carry shipped default patterns gain the codex
|
|
327
|
+
patterns and the bounded codex relative-duration units. Custom patterns are
|
|
328
|
+
never modified.
|
|
329
|
+
Returns True when `loaded` changed and should be rewritten to disk.
|
|
330
|
+
"""
|
|
252
331
|
changed = False
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
332
|
+
patterns = loaded.get("patterns")
|
|
333
|
+
|
|
334
|
+
if isinstance(patterns, list):
|
|
335
|
+
old_norm = _normalized_regex(OLD_DEFAULT_PATTERN)
|
|
336
|
+
old_codex_relative_norm = _normalized_regex(OLD_DEFAULT_PATTERN_CODEX_RELATIVE)
|
|
337
|
+
replaced = False
|
|
338
|
+
migrated: list[Any] = []
|
|
339
|
+
for item in patterns:
|
|
340
|
+
regex = _pattern_regex(item)
|
|
341
|
+
regex_norm = _normalized_regex(regex) if regex is not None else None
|
|
342
|
+
if regex_norm == old_norm:
|
|
343
|
+
if not replaced:
|
|
344
|
+
migrated.extend(json.loads(json.dumps(DEFAULT_CONFIG["patterns"])))
|
|
345
|
+
replaced = True
|
|
346
|
+
changed = True
|
|
347
|
+
elif regex_norm == old_codex_relative_norm:
|
|
348
|
+
if isinstance(item, dict):
|
|
349
|
+
updated = dict(item)
|
|
350
|
+
updated["regex"] = DEFAULT_PATTERN_CODEX_RELATIVE
|
|
351
|
+
migrated.append(updated)
|
|
352
|
+
else:
|
|
353
|
+
migrated.append(DEFAULT_PATTERN_CODEX_RELATIVE)
|
|
354
|
+
changed = True
|
|
355
|
+
else:
|
|
356
|
+
migrated.append(item)
|
|
357
|
+
if changed:
|
|
358
|
+
loaded["patterns"] = migrated
|
|
359
|
+
patterns = migrated
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
config_version = int(loaded.get("version") or 1)
|
|
363
|
+
except (TypeError, ValueError):
|
|
364
|
+
config_version = 1
|
|
365
|
+
|
|
366
|
+
if config_version < CONFIG_VERSION:
|
|
367
|
+
if isinstance(patterns, list):
|
|
368
|
+
norms = {
|
|
369
|
+
_normalized_regex(regex)
|
|
370
|
+
for regex in (_pattern_regex(item) for item in patterns)
|
|
371
|
+
if regex is not None
|
|
372
|
+
}
|
|
373
|
+
has_claude_default = _normalized_regex(DEFAULT_PATTERN) in norms
|
|
374
|
+
has_codex_default = _normalized_regex(DEFAULT_PATTERN_CODEX) in norms
|
|
375
|
+
if has_claude_default and not has_codex_default:
|
|
376
|
+
loaded["patterns"] = list(patterns) + json.loads(json.dumps(_CODEX_DEFAULT_PATTERNS))
|
|
377
|
+
loaded["version"] = CONFIG_VERSION
|
|
378
|
+
changed = True
|
|
379
|
+
|
|
263
380
|
return changed
|
|
264
381
|
|
|
265
382
|
|
|
@@ -275,23 +392,51 @@ def load_config() -> dict[str, Any]:
|
|
|
275
392
|
json.dumps(loaded, ensure_ascii=False, indent=2) + "\n",
|
|
276
393
|
encoding="utf-8",
|
|
277
394
|
)
|
|
278
|
-
log(f"Upgraded
|
|
395
|
+
log(f"Upgraded default limit patterns in {CONFIG_FILE}.")
|
|
279
396
|
except Exception:
|
|
280
|
-
log(f"Upgraded
|
|
397
|
+
log(f"Upgraded default limit patterns in memory only; could not rewrite {CONFIG_FILE}.")
|
|
281
398
|
cfg = deep_merge(cfg, loaded)
|
|
282
399
|
except Exception as exc:
|
|
283
400
|
log(f"Failed to read config file {CONFIG_FILE}: {exc}; using defaults.")
|
|
284
401
|
return cfg
|
|
285
402
|
|
|
286
403
|
|
|
404
|
+
def _system_local_tz():
|
|
405
|
+
"""Timezone of this machine, as a real zone when discoverable.
|
|
406
|
+
|
|
407
|
+
Used for limit messages that state a wall-clock time without naming a
|
|
408
|
+
timezone (codex formats reset times with the machine's local clock).
|
|
409
|
+
"""
|
|
410
|
+
if ZoneInfo is not None:
|
|
411
|
+
env_tz = (os.environ.get("TZ") or "").strip()
|
|
412
|
+
if env_tz:
|
|
413
|
+
try:
|
|
414
|
+
return ZoneInfo(env_tz)
|
|
415
|
+
except Exception:
|
|
416
|
+
# A POSIX-style TZ string; the astimezone() fallback honors it.
|
|
417
|
+
return datetime.now().astimezone().tzinfo or timezone.utc
|
|
418
|
+
try:
|
|
419
|
+
localtime = os.path.realpath("/etc/localtime")
|
|
420
|
+
marker = "zoneinfo/"
|
|
421
|
+
idx = localtime.rfind(marker)
|
|
422
|
+
if idx != -1:
|
|
423
|
+
return ZoneInfo(localtime[idx + len(marker):])
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
return datetime.now().astimezone().tzinfo or timezone.utc
|
|
427
|
+
|
|
428
|
+
|
|
287
429
|
def _tz_from_name(name: str):
|
|
288
430
|
"""Best-effort tzinfo for an IANA name, common abbreviation, or UTC offset.
|
|
289
431
|
|
|
432
|
+
The special names "local" and "system" resolve to this machine's timezone.
|
|
290
433
|
Returns None when the name is unrecognized.
|
|
291
434
|
"""
|
|
292
435
|
name = (name or "").strip()
|
|
293
436
|
if not name:
|
|
294
437
|
return None
|
|
438
|
+
if name.lower() in {"local", "system"}:
|
|
439
|
+
return _system_local_tz()
|
|
295
440
|
if ZoneInfo is None:
|
|
296
441
|
raise RuntimeError("Python 3.9+ is required because autolimit uses zoneinfo.")
|
|
297
442
|
try:
|
|
@@ -415,31 +560,79 @@ def _month_number(token: str) -> int:
|
|
|
415
560
|
raise ValueError(f"unrecognized month name {token!r}")
|
|
416
561
|
|
|
417
562
|
|
|
418
|
-
def parse_reset_at(
|
|
563
|
+
def parse_reset_at(
|
|
564
|
+
match: re.Match[str],
|
|
565
|
+
cfg: dict[str, Any],
|
|
566
|
+
now: Optional[datetime] = None,
|
|
567
|
+
pattern_meta: Optional[dict[str, Any]] = None,
|
|
568
|
+
) -> datetime:
|
|
419
569
|
gd = match.groupdict()
|
|
570
|
+
meta = pattern_meta if isinstance(pattern_meta, dict) else {}
|
|
571
|
+
# The positional-group fallback exists for patterns written entirely
|
|
572
|
+
# without named groups; a partially named pattern must not have its
|
|
573
|
+
# remaining fields misread from unrelated positional groups.
|
|
574
|
+
use_positional = not gd
|
|
420
575
|
|
|
421
576
|
def get_group(name: str, index: int, default: Optional[str] = None) -> Optional[str]:
|
|
422
577
|
value = gd.get(name)
|
|
423
578
|
if value not in {None, ""}:
|
|
424
579
|
return value
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
580
|
+
if use_positional:
|
|
581
|
+
try:
|
|
582
|
+
value = match.group(index)
|
|
583
|
+
if value not in {None, ""}:
|
|
584
|
+
return value
|
|
585
|
+
except Exception:
|
|
586
|
+
pass
|
|
431
587
|
return default
|
|
432
588
|
|
|
589
|
+
max_ahead_hours = config_float(
|
|
590
|
+
cfg, "max_schedule_ahead_hours", "AUTOLIMIT_MAX_SCHEDULE_AHEAD_HOURS", DEFAULT_MAX_SCHEDULE_AHEAD_HOURS
|
|
591
|
+
)
|
|
592
|
+
pattern_max_ahead = meta.get("max_ahead_hours")
|
|
593
|
+
try:
|
|
594
|
+
if isinstance(pattern_max_ahead, (int, float, str)):
|
|
595
|
+
max_ahead_hours = max(max_ahead_hours, float(pattern_max_ahead))
|
|
596
|
+
except (TypeError, ValueError):
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
def check_ahead(reset_at: datetime, reference: datetime, limit_hours: float) -> datetime:
|
|
600
|
+
ahead = reset_at - reference
|
|
601
|
+
if ahead > timedelta(hours=limit_hours):
|
|
602
|
+
raise ValueError(
|
|
603
|
+
f"parsed reset time {reset_at.isoformat(timespec='seconds')} is "
|
|
604
|
+
f"{ahead.total_seconds() / 3600:.1f}h ahead (max {limit_hours:g}h); ignoring as stale"
|
|
605
|
+
)
|
|
606
|
+
return reset_at
|
|
607
|
+
|
|
608
|
+
# Relative messages ("try again in 4 days 23 hours") count from the moment
|
|
609
|
+
# the message is seen, so no timezone or wall-clock work is needed.
|
|
610
|
+
rel_parts: dict[str, int] = {}
|
|
611
|
+
for unit in ("days", "hours", "minutes", "seconds"):
|
|
612
|
+
raw = gd.get(f"rel_{unit}")
|
|
613
|
+
if isinstance(raw, str) and raw:
|
|
614
|
+
rel_parts[unit] = int(raw)
|
|
615
|
+
if rel_parts:
|
|
616
|
+
if now is None:
|
|
617
|
+
now = datetime.now(_system_local_tz())
|
|
618
|
+
reset_at = (now + timedelta(**rel_parts)).replace(microsecond=0)
|
|
619
|
+
return check_ahead(reset_at, now, max_ahead_hours)
|
|
620
|
+
|
|
433
621
|
hour_s = get_group("hour", 1)
|
|
434
622
|
if hour_s is None:
|
|
435
|
-
raise ValueError("limit pattern matched but
|
|
623
|
+
raise ValueError("limit pattern matched but provided neither an hour group nor a relative duration")
|
|
436
624
|
|
|
437
625
|
hour = int(hour_s)
|
|
438
626
|
minute = int(get_group("minute", 2, "0") or "0")
|
|
439
627
|
ampm = (get_group("ampm", 3, "") or "").lower().replace(".", "")
|
|
440
|
-
tzname = (get_group("tzname", 4
|
|
628
|
+
tzname = (get_group("tzname", 4) or "").strip()
|
|
629
|
+
if not tzname:
|
|
630
|
+
# Messages without a timezone (codex prints local wall-clock times) use
|
|
631
|
+
# the pattern's configured timezone, then the global default.
|
|
632
|
+
tzname = str(meta.get("timezone") or "").strip() or default_tz_name(cfg)
|
|
441
633
|
month_s = gd.get("month")
|
|
442
634
|
day_s = gd.get("day")
|
|
635
|
+
year_s = gd.get("year")
|
|
443
636
|
|
|
444
637
|
if ampm:
|
|
445
638
|
if ampm == "pm" and hour != 12:
|
|
@@ -450,15 +643,17 @@ def parse_reset_at(match: re.Match[str], cfg: dict[str, Any], now: Optional[date
|
|
|
450
643
|
tz = resolve_timezone(tzname, cfg)
|
|
451
644
|
now = datetime.now(tz) if now is None else now.astimezone(tz)
|
|
452
645
|
|
|
453
|
-
max_ahead_hours = config_float(
|
|
454
|
-
cfg, "max_schedule_ahead_hours", "AUTOLIMIT_MAX_SCHEDULE_AHEAD_HOURS", DEFAULT_MAX_SCHEDULE_AHEAD_HOURS
|
|
455
|
-
)
|
|
456
|
-
|
|
457
646
|
if month_s and day_s:
|
|
458
647
|
reset_at = now.replace(
|
|
459
648
|
month=_month_number(month_s), day=int(day_s), hour=hour, minute=minute, second=0, microsecond=0
|
|
460
649
|
)
|
|
461
|
-
if
|
|
650
|
+
if year_s:
|
|
651
|
+
reset_at = reset_at.replace(year=int(year_s))
|
|
652
|
+
if reset_at < now - RECENT_PAST_TOLERANCE:
|
|
653
|
+
raise ValueError(
|
|
654
|
+
f"parsed reset time {reset_at.isoformat(timespec='seconds')} is in the past; ignoring as stale"
|
|
655
|
+
)
|
|
656
|
+
elif reset_at < now - RECENT_PAST_TOLERANCE:
|
|
462
657
|
reset_at = reset_at.replace(year=reset_at.year + 1)
|
|
463
658
|
max_ahead_hours = max(max_ahead_hours, DATED_MAX_SCHEDULE_AHEAD_HOURS)
|
|
464
659
|
else:
|
|
@@ -471,14 +666,7 @@ def parse_reset_at(match: re.Match[str], cfg: dict[str, Any], now: Optional[date
|
|
|
471
666
|
# A same-day limit never resets further ahead than a few hours; anything
|
|
472
667
|
# beyond the window is stale text (for example an old message re-fed from
|
|
473
668
|
# scrollback whose time already passed and rolled over to tomorrow).
|
|
474
|
-
|
|
475
|
-
if ahead > timedelta(hours=max_ahead_hours):
|
|
476
|
-
raise ValueError(
|
|
477
|
-
f"parsed reset time {reset_at.isoformat(timespec='seconds')} is "
|
|
478
|
-
f"{ahead.total_seconds() / 3600:.1f}h ahead (max {max_ahead_hours:g}h); ignoring as stale"
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
return reset_at
|
|
669
|
+
return check_ahead(reset_at, now, max_ahead_hours)
|
|
482
670
|
|
|
483
671
|
|
|
484
672
|
class LimitScanner:
|
|
@@ -489,7 +677,7 @@ class LimitScanner:
|
|
|
489
677
|
self.entries: dict[str, dict[str, Any]] = {}
|
|
490
678
|
self.scheduled_ts: set[int] = set()
|
|
491
679
|
self.jobs: list[ScheduledJob] = []
|
|
492
|
-
self.patterns: list[tuple[str, re.Pattern[str]]] = []
|
|
680
|
+
self.patterns: list[tuple[str, re.Pattern[str], dict[str, Any]]] = []
|
|
493
681
|
self._compile_patterns()
|
|
494
682
|
|
|
495
683
|
def _compile_patterns(self) -> None:
|
|
@@ -497,33 +685,40 @@ class LimitScanner:
|
|
|
497
685
|
if not isinstance(raw_patterns, list) or not raw_patterns:
|
|
498
686
|
raw_patterns = DEFAULT_CONFIG["patterns"]
|
|
499
687
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
688
|
+
def add_from(items: list[Any]) -> None:
|
|
689
|
+
for idx, item in enumerate(items):
|
|
690
|
+
meta: dict[str, Any] = {}
|
|
691
|
+
if isinstance(item, str):
|
|
692
|
+
name = f"pattern #{idx + 1}"
|
|
693
|
+
regex = item
|
|
694
|
+
elif isinstance(item, dict):
|
|
695
|
+
name = str(item.get("name") or f"pattern #{idx + 1}")
|
|
696
|
+
regex = item.get("regex")
|
|
697
|
+
meta = {k: item[k] for k in PATTERN_META_KEYS if item.get(k) not in {None, ""}}
|
|
698
|
+
else:
|
|
699
|
+
continue
|
|
509
700
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
701
|
+
if not isinstance(regex, str) or not regex.strip():
|
|
702
|
+
continue
|
|
703
|
+
try:
|
|
704
|
+
self.patterns.append((name, re.compile(regex, re.IGNORECASE | re.DOTALL), meta))
|
|
705
|
+
except re.error as exc:
|
|
706
|
+
log(f"Ignoring invalid regex pattern {name!r}: {exc}", self.cfg)
|
|
516
707
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
708
|
+
add_from(raw_patterns)
|
|
709
|
+
if not self.patterns and raw_patterns is not DEFAULT_CONFIG["patterns"]:
|
|
710
|
+
add_from(DEFAULT_CONFIG["patterns"])
|
|
520
711
|
|
|
521
712
|
@staticmethod
|
|
522
713
|
def _match_key(pattern_name: str, gd: dict[str, Any]) -> str:
|
|
523
714
|
# Built from the captured components (not the raw matched text, which
|
|
524
715
|
# varies across TUI repaints, and not the computed timestamp, which
|
|
525
716
|
# changes once the reset time passes).
|
|
526
|
-
|
|
717
|
+
keys = (
|
|
718
|
+
"month", "day", "year", "hour", "minute", "ampm", "tzname",
|
|
719
|
+
"rel_days", "rel_hours", "rel_minutes", "rel_seconds",
|
|
720
|
+
)
|
|
721
|
+
parts = [pattern_name] + [str(gd.get(k) or "") for k in keys]
|
|
527
722
|
return "|".join(parts)
|
|
528
723
|
|
|
529
724
|
def feed(self, data: bytes) -> list[ScheduledJob]:
|
|
@@ -534,12 +729,12 @@ class LimitScanner:
|
|
|
534
729
|
|
|
535
730
|
now = time.time()
|
|
536
731
|
new_jobs: list[ScheduledJob] = []
|
|
537
|
-
for pattern_name, pattern in self.patterns:
|
|
732
|
+
for pattern_name, pattern, meta in self.patterns:
|
|
538
733
|
for match in pattern.finditer(self.buffer):
|
|
539
734
|
key = self._match_key(pattern_name, match.groupdict())
|
|
540
735
|
entry = self.entries.get(key)
|
|
541
736
|
if entry is None:
|
|
542
|
-
self._schedule_new(key, pattern_name, match, new_jobs)
|
|
737
|
+
self._schedule_new(key, pattern_name, match, new_jobs, meta)
|
|
543
738
|
elif entry["state"] == "fired":
|
|
544
739
|
self._maybe_retry(key, entry, pattern_name, now, new_jobs)
|
|
545
740
|
# pending/failed/dropped entries need no further action.
|
|
@@ -548,9 +743,16 @@ class LimitScanner:
|
|
|
548
743
|
self.jobs.sort(key=lambda job: job.timestamp)
|
|
549
744
|
return new_jobs
|
|
550
745
|
|
|
551
|
-
def _schedule_new(
|
|
746
|
+
def _schedule_new(
|
|
747
|
+
self,
|
|
748
|
+
key: str,
|
|
749
|
+
pattern_name: str,
|
|
750
|
+
match: re.Match[str],
|
|
751
|
+
out: list[ScheduledJob],
|
|
752
|
+
meta: Optional[dict[str, Any]] = None,
|
|
753
|
+
) -> None:
|
|
552
754
|
try:
|
|
553
|
-
reset_at = parse_reset_at(match, self.cfg)
|
|
755
|
+
reset_at = parse_reset_at(match, self.cfg, pattern_meta=meta)
|
|
554
756
|
except Exception as exc:
|
|
555
757
|
self.entries[key] = {"state": "dropped"}
|
|
556
758
|
log(f"Ignoring limit match via {pattern_name!r}: {exc}", self.cfg)
|
|
@@ -1056,6 +1258,7 @@ def print_help() -> None:
|
|
|
1056
1258
|
Usage:
|
|
1057
1259
|
autolimit --cc [args...] Wrap command `cc [args...]`
|
|
1058
1260
|
autolimit --claude [args...] Wrap command `claude [args...]`
|
|
1261
|
+
autolimit --codex [args...] Wrap command `codex [args...]`
|
|
1059
1262
|
autolimit <command> [args...] Wrap an explicit command
|
|
1060
1263
|
autolimit -- <command> [args...] Wrap a command when you want exact parsing
|
|
1061
1264
|
|
|
@@ -1076,6 +1279,7 @@ Examples:
|
|
|
1076
1279
|
autolimit --cc
|
|
1077
1280
|
autolimit --cc --model sonnet
|
|
1078
1281
|
autolimit --claude --dangerously-skip-permissions
|
|
1282
|
+
autolimit --codex
|
|
1079
1283
|
autolimit --direct --python3 my_repl.py
|
|
1080
1284
|
autolimit -- npx some-runner --flag
|
|
1081
1285
|
|
|
@@ -1131,6 +1335,21 @@ def test_parser(cfg: dict[str, Any]) -> int:
|
|
|
1131
1335
|
month = _MONTHS[dated.month - 1].capitalize()
|
|
1132
1336
|
tzn = default_tz_name(cfg)
|
|
1133
1337
|
|
|
1338
|
+
# Codex prints reset times using the machine's local clock.
|
|
1339
|
+
local_now = datetime.now(_system_local_tz())
|
|
1340
|
+
codex_soon = local_now + timedelta(hours=2)
|
|
1341
|
+
codex_dated = local_now + timedelta(days=3)
|
|
1342
|
+
codex_month = _MONTHS[codex_dated.month - 1].capitalize()
|
|
1343
|
+
|
|
1344
|
+
def codex_clock(dt: datetime) -> str:
|
|
1345
|
+
hour = dt.hour % 12 or 12
|
|
1346
|
+
return f"{hour}:{dt.minute:02d} {'AM' if dt.hour < 12 else 'PM'}"
|
|
1347
|
+
|
|
1348
|
+
def day_suffix(day: int) -> str:
|
|
1349
|
+
if 11 <= day <= 13:
|
|
1350
|
+
return "th"
|
|
1351
|
+
return {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
1352
|
+
|
|
1134
1353
|
# (example line, should it schedule a job?)
|
|
1135
1354
|
examples = [
|
|
1136
1355
|
(f"You've hit your session limit · resets {clock(soon)} ({tzn})", True),
|
|
@@ -1138,6 +1357,18 @@ def test_parser(cfg: dict[str, Any]) -> int:
|
|
|
1138
1357
|
(f"You've hit your session limit · resets {clock(soon, ampm=False)} ({tzn})", True),
|
|
1139
1358
|
(f"You've hit your weekly limit · resets {month} {dated.day} at {clock(dated)} ({tzn})", True),
|
|
1140
1359
|
(f"You've hit your session limit · resets {clock(now - timedelta(hours=3))} ({tzn})", False),
|
|
1360
|
+
(f"You've hit your usage limit. Try again at {codex_clock(codex_soon)}.", True),
|
|
1361
|
+
(
|
|
1362
|
+
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), "
|
|
1363
|
+
"visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at "
|
|
1364
|
+
f"{codex_month} {codex_dated.day}{day_suffix(codex_dated.day)}, {codex_dated.year} {codex_clock(codex_dated)}.",
|
|
1365
|
+
True,
|
|
1366
|
+
),
|
|
1367
|
+
("You've hit your usage limit. Try again in 3 hours 26 minutes.", True),
|
|
1368
|
+
("You've hit your usage limit. Try again in 2 months.", False),
|
|
1369
|
+
("You've hit your usage limit. Try again in 500ms.", False),
|
|
1370
|
+
("You've hit your usage limit. Try again later.", False),
|
|
1371
|
+
(f"You've hit your usage limit. Try again at {codex_clock(local_now - timedelta(hours=3))}.", False),
|
|
1141
1372
|
]
|
|
1142
1373
|
|
|
1143
1374
|
ok = True
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autolimit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Terminal-agnostic PTY wrapper that resumes CLI runners after reset-time usage limits.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Terminal-agnostic PTY wrapper that resumes CLI runners (Claude Code, Codex, ...) after reset-time usage limits.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/betive37/autolimit"
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"postinstall": "node scripts/postinstall.js",
|
|
14
14
|
"prepublishOnly": "npm test",
|
|
15
|
-
"test": "python3 tests/test_autolimit.py && node tests/test_postinstall_star.js && node bin/autolimit.js --test-parser && node bin/autolimit.js --dry-run --cc --example"
|
|
15
|
+
"test": "python3 tests/test_autolimit.py && node tests/test_postinstall_star.js && node bin/autolimit.js --test-parser && node bin/autolimit.js --dry-run --cc --example && node bin/autolimit.js --dry-run --codex"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"terminal",
|
|
19
19
|
"pty",
|
|
20
20
|
"automation",
|
|
21
21
|
"claude-code",
|
|
22
|
+
"codex",
|
|
22
23
|
"wrapper"
|
|
23
24
|
],
|
|
24
25
|
"author": "Jae Hyun <betive37@gmail.com>",
|
package/scripts/postinstall.js
CHANGED
|
@@ -19,9 +19,13 @@ const localBin = path.join(home, '.local', 'bin');
|
|
|
19
19
|
const symlink = path.join(localBin, 'autolimit');
|
|
20
20
|
|
|
21
21
|
const defaultMessage = '이어서 계속 진행해줘.\n';
|
|
22
|
-
// Keep in sync with DEFAULT_PATTERN
|
|
22
|
+
// Keep in sync with the DEFAULT_PATTERN* constants and CONFIG_VERSION in lib/autolimit.py.
|
|
23
|
+
const configVersion = 3;
|
|
23
24
|
const defaultPatternTime = "You[\\u2019']?ve\\s+hit\\s+your(?:\\s+[\\w-]+)?\\s+limit.{0,120}?resets\\s+(?P<hour>\\d{1,2})(?::(?P<minute>\\d{2}))?\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)?\\s*\\((?P<tzname>[^)]{1,64})\\)";
|
|
24
25
|
const defaultPatternDated = "You[\\u2019']?ve\\s+hit\\s+your(?:\\s+[\\w-]+)?\\s+limit.{0,120}?resets\\s+(?P<month>[A-Za-z]{3,9})\\.?\\s+(?P<day>\\d{1,2})(?:st|nd|rd|th)?,?\\s*(?:at\\s+)?(?P<hour>\\d{1,2})(?::(?P<minute>\\d{2}))?\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)?\\s*\\((?P<tzname>[^)]{1,64})\\)";
|
|
26
|
+
const defaultPatternCodex = "You[\\u2019']?ve\\s+hit\\s+your\\s+usage\\s+limit.{0,200}?try\\s+again\\s+at\\s+(?P<hour>\\d{1,2}):(?P<minute>\\d{2})\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)";
|
|
27
|
+
const defaultPatternCodexDated = "You[\\u2019']?ve\\s+hit\\s+your\\s+usage\\s+limit.{0,200}?try\\s+again\\s+at\\s+(?P<month>[A-Za-z]{3,9})\\.?\\s+(?P<day>\\d{1,2})(?:st|nd|rd|th)?,?\\s*(?:(?P<year>\\d{4})\\s+)?(?P<hour>\\d{1,2}):(?P<minute>\\d{2})\\s*(?P<ampm>[aApP]\\.?[mM]\\.?)";
|
|
28
|
+
const defaultPatternCodexRelative = "You[\\u2019']?ve\\s+hit\\s+your\\s+usage\\s+limit.{0,200}?try\\s+again\\s+in\\s+(?=\\d+\\s*(?:d(?:ays?)?|h(?:(?:ou)?rs?)?|m(?:in(?:ute)?s?)?|s(?:ec(?:ond)?s?))\\b)(?:(?P<rel_days>\\d+)\\s*d(?:ays?)?\\b)?[,\\s]*(?:(?P<rel_hours>\\d+)\\s*h(?:(?:ou)?rs?)?\\b)?[,\\s]*(?:(?P<rel_minutes>\\d+)\\s*m(?:in(?:ute)?s?)?\\b)?[,\\s]*(?:(?P<rel_seconds>\\d+)\\s*s(?:ec(?:ond)?s?)?\\b)?";
|
|
25
29
|
|
|
26
30
|
function chmodX(file) {
|
|
27
31
|
try {
|
|
@@ -53,7 +57,7 @@ function ensureConfigFile() {
|
|
|
53
57
|
ensureDir(configDir);
|
|
54
58
|
if (fs.existsSync(configFile)) return;
|
|
55
59
|
const config = {
|
|
56
|
-
version:
|
|
60
|
+
version: configVersion,
|
|
57
61
|
message_file: messageFile,
|
|
58
62
|
log_file: path.join(configDir, 'limit-watch.log'),
|
|
59
63
|
default_timezone: 'Asia/Seoul',
|
|
@@ -68,6 +72,21 @@ function ensureConfigFile() {
|
|
|
68
72
|
{
|
|
69
73
|
name: 'Claude-style dated limit',
|
|
70
74
|
regex: defaultPatternDated
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'Codex-style usage limit',
|
|
78
|
+
regex: defaultPatternCodex,
|
|
79
|
+
timezone: 'local'
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'Codex-style dated usage limit',
|
|
83
|
+
regex: defaultPatternCodexDated,
|
|
84
|
+
timezone: 'local'
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'Codex-style relative usage limit',
|
|
88
|
+
regex: defaultPatternCodexRelative,
|
|
89
|
+
max_ahead_hours: 192
|
|
71
90
|
}
|
|
72
91
|
],
|
|
73
92
|
runners: {
|
|
@@ -76,6 +95,9 @@ function ensureConfigFile() {
|
|
|
76
95
|
},
|
|
77
96
|
cc: {
|
|
78
97
|
fallback: ['{runner}', '{args}', '-c', '-p', '{message}']
|
|
98
|
+
},
|
|
99
|
+
codex: {
|
|
100
|
+
fallback: ['{runner}', 'exec', 'resume', '--last', '{message}']
|
|
79
101
|
}
|
|
80
102
|
}
|
|
81
103
|
};
|
|
@@ -115,7 +137,7 @@ async function main() {
|
|
|
115
137
|
ensureConfigFile();
|
|
116
138
|
ensureSymlink();
|
|
117
139
|
|
|
118
|
-
console.log('[autolimit] Installed. Try: autolimit --cc');
|
|
140
|
+
console.log('[autolimit] Installed. Try: autolimit --cc or autolimit --codex');
|
|
119
141
|
console.log(`[autolimit] Message file: ${messageFile}`);
|
|
120
142
|
console.log(`[autolimit] Config file: ${configFile}`);
|
|
121
143
|
console.log(`[autolimit] PATH helper: ${symlink}`);
|