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 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 was built for Claude Code style session-limit messages such as:
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
- 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). Time-of-day resets (`resets 7:40pm`, `resets 19:40`) and dated resets (`resets Jul 8 at 10:59am`) are both recognized.
11
+ and OpenAI Codex CLI usage-limit messages:
12
12
 
13
- 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` and `cc`.
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": 1,
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
- A `config.json` that still contains the v0.1 default pattern is upgraded to the current defaults automatically on the next run (custom patterns are left untouched).
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. A pattern must provide these captures, either by name or by positional group:
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`, ...), or an offset (`UTC+9`); unknown names fall back to `default_timezone` and are logged
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.2.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": 1,
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 = loaded.get("patterns")
249
- if not isinstance(patterns, list):
250
- return False
251
- old_norm = _normalized_regex(OLD_DEFAULT_PATTERN)
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
- migrated: list[Any] = []
254
- for item in patterns:
255
- regex = item.get("regex") if isinstance(item, dict) else item
256
- if isinstance(regex, str) and _normalized_regex(regex) == old_norm:
257
- migrated.extend(json.loads(json.dumps(DEFAULT_CONFIG["patterns"])))
258
- changed = True
259
- else:
260
- migrated.append(item)
261
- if changed:
262
- loaded["patterns"] = migrated
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 legacy default limit pattern in {CONFIG_FILE}.")
395
+ log(f"Upgraded default limit patterns in {CONFIG_FILE}.")
279
396
  except Exception:
280
- log(f"Upgraded legacy default limit pattern in memory only; could not rewrite {CONFIG_FILE}.")
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(match: re.Match[str], cfg: dict[str, Any], now: Optional[datetime] = None) -> datetime:
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
- try:
426
- value = match.group(index)
427
- if value not in {None, ""}:
428
- return value
429
- except Exception:
430
- pass
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 did not provide an hour group")
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, default_tz_name(cfg)) or default_tz_name(cfg)).strip()
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 reset_at < now - RECENT_PAST_TOLERANCE:
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
- ahead = reset_at - now
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
- for idx, item in enumerate(raw_patterns):
501
- if isinstance(item, str):
502
- name = f"pattern #{idx + 1}"
503
- regex = item
504
- elif isinstance(item, dict):
505
- name = str(item.get("name") or f"pattern #{idx + 1}")
506
- regex = item.get("regex")
507
- else:
508
- continue
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
- if not isinstance(regex, str) or not regex.strip():
511
- continue
512
- try:
513
- self.patterns.append((name, re.compile(regex, re.IGNORECASE | re.DOTALL)))
514
- except re.error as exc:
515
- log(f"Ignoring invalid regex pattern {name!r}: {exc}", self.cfg)
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
- if not self.patterns:
518
- self.patterns.append(("default Claude-style session limit", re.compile(DEFAULT_PATTERN, re.IGNORECASE | re.DOTALL)))
519
- self.patterns.append(("default Claude-style dated limit", re.compile(DEFAULT_PATTERN_DATED, re.IGNORECASE | re.DOTALL)))
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
- parts = [pattern_name] + [str(gd.get(k) or "") for k in ("month", "day", "hour", "minute", "ampm", "tzname")]
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(self, key: str, pattern_name: str, match: re.Match[str], out: list[ScheduledJob]) -> None:
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.2.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>",
@@ -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 / DEFAULT_PATTERN_DATED in lib/autolimit.py.
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: 1,
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}`);