autolimit 0.2.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.
@@ -0,0 +1,1215 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ autolimit: terminal-agnostic PTY wrapper for CLI tools with reset-time limit messages.
4
+
5
+ It launches an arbitrary runner inside a pseudo-terminal, mirrors stdin/stdout,
6
+ detects configured limit messages, and automatically sends a configured message
7
+ when the reset time arrives.
8
+
9
+ This file intentionally uses only the Python standard library.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import errno
14
+ import fcntl
15
+ import json
16
+ import os
17
+ import pty
18
+ import re
19
+ import select
20
+ import shlex
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import termios
25
+ import time
26
+ import tty
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timedelta, timezone
29
+ from pathlib import Path
30
+ from typing import Any, Iterable, Optional
31
+
32
+ try:
33
+ from zoneinfo import ZoneInfo
34
+ except Exception: # pragma: no cover - Python < 3.9 guard
35
+ ZoneInfo = None # type: ignore
36
+
37
+ VERSION = "0.2.0"
38
+
39
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "autolimit"
40
+ CONFIG_DIR = Path(os.environ.get("AUTOLIMIT_CONFIG_DIR", str(DEFAULT_CONFIG_DIR))).expanduser()
41
+ CONFIG_FILE = Path(os.environ.get("AUTOLIMIT_CONFIG_FILE", str(CONFIG_DIR / "config.json"))).expanduser()
42
+ MESSAGE_FILE = Path(os.environ.get("AUTOLIMIT_MESSAGE_FILE", str(CONFIG_DIR / "message.txt"))).expanduser()
43
+ LOG_FILE = Path(os.environ.get("AUTOLIMIT_LOG_FILE", str(CONFIG_DIR / "limit-watch.log"))).expanduser()
44
+
45
+ LEGACY_MESSAGE_FILE = Path.home() / ".config" / "claude-auto" / "message.txt"
46
+
47
+ DEFAULT_MESSAGE = "이어서 계속 진행해줘."
48
+ DEFAULT_TZ_NAME = os.environ.get("AUTOLIMIT_DEFAULT_TZ", "Asia/Seoul")
49
+ DEFAULT_SEND_GRACE_SECONDS = 5.0
50
+
51
+ # A parsed reset time further ahead than this is treated as stale text (for
52
+ # example an already-handled message re-fed from scrollback after midnight
53
+ # rollover) and is ignored instead of scheduled.
54
+ DEFAULT_MAX_SCHEDULE_AHEAD_HOURS = 12.0
55
+ DATED_MAX_SCHEDULE_AHEAD_HOURS = 8 * 24.0 # date-carrying (weekly) messages
56
+
57
+ # Fallback-after-exit refuses to sleep longer than this.
58
+ DEFAULT_MAX_FALLBACK_WAIT_HOURS = 26.0
59
+
60
+ # A reset time up to this far in the past still means "send now" (fresh
61
+ # messages truncate seconds and can be read a little late); anything older
62
+ # rolls over to the next day/year and is then subject to the stale guard.
63
+ RECENT_PAST_TOLERANCE = timedelta(minutes=10)
64
+
65
+ # If the wrapped process exits within this many seconds of a live auto-send,
66
+ # assume the message was never processed and run the fallback instead.
67
+ SEND_CONFIRM_SECONDS = 10.0
68
+
69
+ # If the same limit message is still being displayed this long after an
70
+ # auto-send, the send did not unblock the session: retry with backoff.
71
+ RETRY_QUIET_SECONDS = 30.0
72
+ RETRY_BACKOFF_SECONDS = (60.0, 120.0, 240.0)
73
+
74
+ # Fixed-offset fallbacks for common abbreviations that zoneinfo does not know.
75
+ # Deliberately omits ambiguous ones such as CST (US Central vs China) and IST.
76
+ TZ_ABBREVIATIONS: dict[str, float] = {
77
+ "UTC": 0, "GMT": 0,
78
+ "KST": 9, "JST": 9,
79
+ "HKT": 8, "SGT": 8, "AWST": 8,
80
+ "ICT": 7, "WIB": 7,
81
+ "PST": -8, "PDT": -7,
82
+ "MST": -7, "MDT": -6,
83
+ "CDT": -5,
84
+ "EST": -5, "EDT": -4,
85
+ "BST": 1, "CET": 1, "CEST": 2, "EET": 2, "EEST": 3, "MSK": 3,
86
+ "ACST": 9.5, "AEST": 10, "AEDT": 11, "NZST": 12, "NZDT": 13,
87
+ }
88
+
89
+ _TZ_OFFSET_RE = re.compile(r"^(?:UTC|GMT)\s*([+-])(\d{1,2})(?::?(\d{2}))?$", re.IGNORECASE)
90
+
91
+ _MONTHS = ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec")
92
+
93
+ ANSI_RE = re.compile(rb"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
94
+
95
+ # The v0.1 default pattern. Existing config files that still carry it are
96
+ # upgraded in place by load_config().
97
+ OLD_DEFAULT_PATTERN = (
98
+ r"You[\u2019']?ve\s+hit\s+your\s+session\s+limit.*?"
99
+ r"resets\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*"
100
+ r"(?P<ampm>[aApP]\.?[mM]\.?)\s*\((?P<tzname>[^)]+)\)"
101
+ )
102
+
103
+ # Bounded gap (instead of `.*?`) so unrelated text cannot bridge a match, an
104
+ # optional am/pm so 24-hour clocks work, and a broadened "your ... limit"
105
+ # prefix for session/usage/weekly wording.
106
+ DEFAULT_PATTERN = (
107
+ r"You[\u2019']?ve\s+hit\s+your(?:\s+[\w-]+)?\s+limit.{0,120}?"
108
+ r"resets\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*"
109
+ r"(?P<ampm>[aApP]\.?[mM]\.?)?\s*\((?P<tzname>[^)]{1,64})\)"
110
+ )
111
+
112
+ # Date-carrying variant such as "resets Jul 8 at 10:59am (Asia/Seoul)".
113
+ DEFAULT_PATTERN_DATED = (
114
+ r"You[\u2019']?ve\s+hit\s+your(?:\s+[\w-]+)?\s+limit.{0,120}?"
115
+ r"resets\s+(?P<month>[A-Za-z]{3,9})\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?,?\s*(?:at\s+)?"
116
+ r"(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*"
117
+ r"(?P<ampm>[aApP]\.?[mM]\.?)?\s*\((?P<tzname>[^)]{1,64})\)"
118
+ )
119
+
120
+ DEFAULT_CONFIG: dict[str, Any] = {
121
+ "version": 1,
122
+ "message_file": str(MESSAGE_FILE),
123
+ "log_file": str(LOG_FILE),
124
+ "default_timezone": DEFAULT_TZ_NAME,
125
+ "send_grace_seconds": DEFAULT_SEND_GRACE_SECONDS,
126
+ "max_schedule_ahead_hours": DEFAULT_MAX_SCHEDULE_AHEAD_HOURS,
127
+ "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
+ ],
138
+ "runners": {
139
+ # Fallback is only used when the wrapped process exits after printing a
140
+ # limit message. For Claude Code style runners, `-c -p <message>` resumes
141
+ # the most recent conversation non-interactively.
142
+ "claude": {
143
+ "fallback": ["{runner}", "{args}", "-c", "-p", "{message}"],
144
+ },
145
+ "cc": {
146
+ "fallback": ["{runner}", "{args}", "-c", "-p", "{message}"],
147
+ },
148
+ },
149
+ }
150
+
151
+ RESERVED_COMMANDS = {
152
+ "--help",
153
+ "-h",
154
+ "--version",
155
+ "-V",
156
+ "--show-config",
157
+ "--test-parser",
158
+ "--init",
159
+ "--list-runners",
160
+ "--dry-run",
161
+ "--direct",
162
+ "--shell",
163
+ "--no-fallback",
164
+ "--autolimit-version",
165
+ "--autolimit-show-config",
166
+ "--autolimit-test-parser",
167
+ # Compatibility with the earlier claude-auto package.
168
+ "--claude-auto-version",
169
+ "--claude-auto-show-config",
170
+ "--claude-auto-test-parser",
171
+ }
172
+
173
+ KNOWN_WRAPPER_FLAGS = {"--direct", "--shell", "--no-fallback", "--dry-run"}
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class ScheduledJob:
178
+ timestamp: int
179
+ reset_at: datetime
180
+ pattern_name: str
181
+ # Stable identity of the matched message (pattern + captured groups) so a
182
+ # re-displayed message maps back to the same job across repaints.
183
+ text_key: str = ""
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class RunnerInvocation:
188
+ command: list[str]
189
+ runner_name: str
190
+ mode: str # "shell" or "direct"
191
+ fallback_allowed: bool = True
192
+
193
+
194
+ class AutolimitError(Exception):
195
+ pass
196
+
197
+
198
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
199
+ result = dict(base)
200
+ for key, value in override.items():
201
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
202
+ result[key] = deep_merge(result[key], value) # type: ignore[arg-type]
203
+ else:
204
+ result[key] = value
205
+ return result
206
+
207
+
208
+ def expand_path(value: Any, default: Path) -> Path:
209
+ if not isinstance(value, str) or not value.strip():
210
+ return default
211
+ return Path(value).expanduser()
212
+
213
+
214
+ def default_config_for_file() -> dict[str, Any]:
215
+ cfg = json.loads(json.dumps(DEFAULT_CONFIG))
216
+ cfg["message_file"] = str(MESSAGE_FILE)
217
+ cfg["log_file"] = str(LOG_FILE)
218
+ cfg["default_timezone"] = DEFAULT_TZ_NAME
219
+ return cfg
220
+
221
+
222
+ def ensure_default_config() -> None:
223
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
224
+
225
+ if not MESSAGE_FILE.exists():
226
+ if LEGACY_MESSAGE_FILE.exists():
227
+ try:
228
+ MESSAGE_FILE.write_text(LEGACY_MESSAGE_FILE.read_text(encoding="utf-8"), encoding="utf-8")
229
+ except Exception:
230
+ MESSAGE_FILE.write_text(DEFAULT_MESSAGE + "\n", encoding="utf-8")
231
+ else:
232
+ MESSAGE_FILE.write_text(DEFAULT_MESSAGE + "\n", encoding="utf-8")
233
+
234
+ if not CONFIG_FILE.exists():
235
+ CONFIG_FILE.write_text(
236
+ json.dumps(default_config_for_file(), ensure_ascii=False, indent=2) + "\n",
237
+ encoding="utf-8",
238
+ )
239
+
240
+
241
+ def _normalized_regex(value: Any) -> str:
242
+ # Config files may carry the curly apostrophe either as a literal character
243
+ # or as the ’ regex escape; compare both forms the same way.
244
+ return str(value).replace("’", "\\u2019")
245
+
246
+
247
+ 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)
252
+ 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
263
+ return changed
264
+
265
+
266
+ def load_config() -> dict[str, Any]:
267
+ ensure_default_config()
268
+ cfg = default_config_for_file()
269
+ try:
270
+ loaded = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
271
+ if isinstance(loaded, dict):
272
+ if migrate_legacy_patterns(loaded):
273
+ try:
274
+ CONFIG_FILE.write_text(
275
+ json.dumps(loaded, ensure_ascii=False, indent=2) + "\n",
276
+ encoding="utf-8",
277
+ )
278
+ log(f"Upgraded legacy default limit pattern in {CONFIG_FILE}.")
279
+ except Exception:
280
+ log(f"Upgraded legacy default limit pattern in memory only; could not rewrite {CONFIG_FILE}.")
281
+ cfg = deep_merge(cfg, loaded)
282
+ except Exception as exc:
283
+ log(f"Failed to read config file {CONFIG_FILE}: {exc}; using defaults.")
284
+ return cfg
285
+
286
+
287
+ def _tz_from_name(name: str):
288
+ """Best-effort tzinfo for an IANA name, common abbreviation, or UTC offset.
289
+
290
+ Returns None when the name is unrecognized.
291
+ """
292
+ name = (name or "").strip()
293
+ if not name:
294
+ return None
295
+ if ZoneInfo is None:
296
+ raise RuntimeError("Python 3.9+ is required because autolimit uses zoneinfo.")
297
+ try:
298
+ return ZoneInfo(name)
299
+ except Exception:
300
+ pass
301
+ offset = TZ_ABBREVIATIONS.get(name.upper())
302
+ if offset is not None:
303
+ return timezone(timedelta(hours=offset), name.upper())
304
+ match = _TZ_OFFSET_RE.match(name)
305
+ if match:
306
+ sign = -1 if match.group(1) == "-" else 1
307
+ delta = timedelta(hours=int(match.group(2)), minutes=int(match.group(3) or 0))
308
+ return timezone(sign * delta, name)
309
+ return None
310
+
311
+
312
+ def resolve_timezone(name: str, cfg: Optional[dict[str, Any]] = None):
313
+ tz = _tz_from_name(name)
314
+ if tz is not None:
315
+ return tz
316
+ fallback_name = default_tz_name(cfg) if cfg is not None else DEFAULT_TZ_NAME
317
+ tz = _tz_from_name(fallback_name)
318
+ if tz is None:
319
+ tz = timezone.utc
320
+ fallback_name = "UTC"
321
+ log(f"Unknown timezone {name!r} in limit message; assuming {fallback_name!r}.", cfg)
322
+ return tz
323
+
324
+
325
+ def _now_for_log() -> str:
326
+ try:
327
+ tz = _tz_from_name(DEFAULT_TZ_NAME)
328
+ if tz is not None:
329
+ return datetime.now(tz).isoformat(timespec="seconds")
330
+ except Exception:
331
+ pass
332
+ return datetime.now().isoformat(timespec="seconds")
333
+
334
+
335
+ def log(message: str, cfg: Optional[dict[str, Any]] = None) -> None:
336
+ if os.environ.get("AUTOLIMIT_LOG", "1") in {"0", "false", "False", "no", "NO"}:
337
+ return
338
+ try:
339
+ if os.environ.get("AUTOLIMIT_LOG_FILE"):
340
+ # Environment override wins, mirroring AUTOLIMIT_MESSAGE_FILE.
341
+ log_file = LOG_FILE
342
+ elif cfg is not None:
343
+ log_file = expand_path(cfg.get("log_file"), LOG_FILE)
344
+ else:
345
+ log_file = LOG_FILE
346
+ log_file.parent.mkdir(parents=True, exist_ok=True)
347
+ with log_file.open("a", encoding="utf-8") as f:
348
+ f.write(f"[{_now_for_log()}] {message}\n")
349
+ except Exception:
350
+ pass
351
+
352
+
353
+ def configured_message_file(cfg: dict[str, Any]) -> Path:
354
+ env_path = os.environ.get("AUTOLIMIT_MESSAGE_FILE")
355
+ if env_path:
356
+ return Path(env_path).expanduser()
357
+ return expand_path(cfg.get("message_file"), MESSAGE_FILE)
358
+
359
+
360
+ def load_message(cfg: dict[str, Any]) -> str:
361
+ env_message = os.environ.get("AUTOLIMIT_MESSAGE")
362
+ if env_message is not None:
363
+ return " ".join(env_message.splitlines()).strip()
364
+
365
+ message_file = configured_message_file(cfg)
366
+ if not message_file.exists():
367
+ try:
368
+ message_file.parent.mkdir(parents=True, exist_ok=True)
369
+ message_file.write_text(DEFAULT_MESSAGE + "\n", encoding="utf-8")
370
+ except Exception:
371
+ return DEFAULT_MESSAGE
372
+
373
+ try:
374
+ return " ".join(message_file.read_text(encoding="utf-8").splitlines()).strip()
375
+ except Exception:
376
+ return DEFAULT_MESSAGE
377
+
378
+
379
+ def config_float(cfg: Optional[dict[str, Any]], key: str, env_name: str, default: float) -> float:
380
+ raw = os.environ.get(env_name)
381
+ if raw is None and isinstance(cfg, dict):
382
+ raw = cfg.get(key, default)
383
+ if raw is None:
384
+ raw = default
385
+ try:
386
+ return float(raw)
387
+ except Exception:
388
+ return default
389
+
390
+
391
+ def send_grace_seconds(cfg: dict[str, Any]) -> float:
392
+ value = config_float(cfg, "send_grace_seconds", "AUTOLIMIT_SEND_GRACE_SECONDS", DEFAULT_SEND_GRACE_SECONDS)
393
+ return max(0.0, value)
394
+
395
+
396
+ def default_tz_name(cfg: dict[str, Any]) -> str:
397
+ raw = os.environ.get("AUTOLIMIT_DEFAULT_TZ", cfg.get("default_timezone", DEFAULT_TZ_NAME))
398
+ return str(raw or DEFAULT_TZ_NAME)
399
+
400
+
401
+ def shell_join(argv: Iterable[str]) -> str:
402
+ return " ".join(shlex.quote(str(x)) for x in argv)
403
+
404
+
405
+ def strip_ansi_bytes(data: bytes) -> str:
406
+ cleaned = ANSI_RE.sub(b"", data)
407
+ return cleaned.decode("utf-8", errors="replace")
408
+
409
+
410
+ def _month_number(token: str) -> int:
411
+ prefix = token.strip().lower()[:3]
412
+ for idx, name in enumerate(_MONTHS, start=1):
413
+ if prefix == name:
414
+ return idx
415
+ raise ValueError(f"unrecognized month name {token!r}")
416
+
417
+
418
+ def parse_reset_at(match: re.Match[str], cfg: dict[str, Any], now: Optional[datetime] = None) -> datetime:
419
+ gd = match.groupdict()
420
+
421
+ def get_group(name: str, index: int, default: Optional[str] = None) -> Optional[str]:
422
+ value = gd.get(name)
423
+ if value not in {None, ""}:
424
+ return value
425
+ try:
426
+ value = match.group(index)
427
+ if value not in {None, ""}:
428
+ return value
429
+ except Exception:
430
+ pass
431
+ return default
432
+
433
+ hour_s = get_group("hour", 1)
434
+ if hour_s is None:
435
+ raise ValueError("limit pattern matched but did not provide an hour group")
436
+
437
+ hour = int(hour_s)
438
+ minute = int(get_group("minute", 2, "0") or "0")
439
+ ampm = (get_group("ampm", 3, "") or "").lower().replace(".", "")
440
+ tzname = (get_group("tzname", 4, default_tz_name(cfg)) or default_tz_name(cfg)).strip()
441
+ month_s = gd.get("month")
442
+ day_s = gd.get("day")
443
+
444
+ if ampm:
445
+ if ampm == "pm" and hour != 12:
446
+ hour += 12
447
+ elif ampm == "am" and hour == 12:
448
+ hour = 0
449
+
450
+ tz = resolve_timezone(tzname, cfg)
451
+ now = datetime.now(tz) if now is None else now.astimezone(tz)
452
+
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
+ if month_s and day_s:
458
+ reset_at = now.replace(
459
+ month=_month_number(month_s), day=int(day_s), hour=hour, minute=minute, second=0, microsecond=0
460
+ )
461
+ if reset_at < now - RECENT_PAST_TOLERANCE:
462
+ reset_at = reset_at.replace(year=reset_at.year + 1)
463
+ max_ahead_hours = max(max_ahead_hours, DATED_MAX_SCHEDULE_AHEAD_HOURS)
464
+ else:
465
+ reset_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
466
+ # If the reset time is recently past, send immediately. If it is
467
+ # materially in the past, the message likely refers to tomorrow.
468
+ if reset_at < now - RECENT_PAST_TOLERANCE:
469
+ reset_at += timedelta(days=1)
470
+
471
+ # A same-day limit never resets further ahead than a few hours; anything
472
+ # beyond the window is stale text (for example an old message re-fed from
473
+ # 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
482
+
483
+
484
+ class LimitScanner:
485
+ def __init__(self, cfg: dict[str, Any]) -> None:
486
+ self.cfg = cfg
487
+ self.buffer = ""
488
+ # Match identity -> {"state": pending|fired|failed|dropped, "attempts", "fired_at", ...}
489
+ self.entries: dict[str, dict[str, Any]] = {}
490
+ self.scheduled_ts: set[int] = set()
491
+ self.jobs: list[ScheduledJob] = []
492
+ self.patterns: list[tuple[str, re.Pattern[str]]] = []
493
+ self._compile_patterns()
494
+
495
+ def _compile_patterns(self) -> None:
496
+ raw_patterns = self.cfg.get("patterns")
497
+ if not isinstance(raw_patterns, list) or not raw_patterns:
498
+ raw_patterns = DEFAULT_CONFIG["patterns"]
499
+
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
509
+
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)
516
+
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)))
520
+
521
+ @staticmethod
522
+ def _match_key(pattern_name: str, gd: dict[str, Any]) -> str:
523
+ # Built from the captured components (not the raw matched text, which
524
+ # varies across TUI repaints, and not the computed timestamp, which
525
+ # changes once the reset time passes).
526
+ parts = [pattern_name] + [str(gd.get(k) or "") for k in ("month", "day", "hour", "minute", "ampm", "tzname")]
527
+ return "|".join(parts)
528
+
529
+ def feed(self, data: bytes) -> list[ScheduledJob]:
530
+ text = strip_ansi_bytes(data).replace("\r", "\n")
531
+ # Keep enough scrollback for messages split across redraw chunks without
532
+ # allowing unbounded growth.
533
+ self.buffer = (self.buffer + text)[-12000:]
534
+
535
+ now = time.time()
536
+ new_jobs: list[ScheduledJob] = []
537
+ for pattern_name, pattern in self.patterns:
538
+ for match in pattern.finditer(self.buffer):
539
+ key = self._match_key(pattern_name, match.groupdict())
540
+ entry = self.entries.get(key)
541
+ if entry is None:
542
+ self._schedule_new(key, pattern_name, match, new_jobs)
543
+ elif entry["state"] == "fired":
544
+ self._maybe_retry(key, entry, pattern_name, now, new_jobs)
545
+ # pending/failed/dropped entries need no further action.
546
+
547
+ if new_jobs:
548
+ self.jobs.sort(key=lambda job: job.timestamp)
549
+ return new_jobs
550
+
551
+ def _schedule_new(self, key: str, pattern_name: str, match: re.Match[str], out: list[ScheduledJob]) -> None:
552
+ try:
553
+ reset_at = parse_reset_at(match, self.cfg)
554
+ except Exception as exc:
555
+ self.entries[key] = {"state": "dropped"}
556
+ log(f"Ignoring limit match via {pattern_name!r}: {exc}", self.cfg)
557
+ return
558
+
559
+ ts = int(reset_at.timestamp())
560
+ if ts in self.scheduled_ts:
561
+ # Another pattern already scheduled this exact reset time.
562
+ self.entries[key] = {"state": "dropped"}
563
+ return
564
+
565
+ job = ScheduledJob(timestamp=ts, reset_at=reset_at, pattern_name=pattern_name, text_key=key)
566
+ self.scheduled_ts.add(ts)
567
+ self.entries[key] = {"state": "pending", "attempts": 0, "fired_at": None}
568
+ self.jobs.append(job)
569
+ out.append(job)
570
+ log(
571
+ f"Detected limit via {pattern_name!r}; scheduled auto-send at {reset_at.isoformat(timespec='seconds')}",
572
+ self.cfg,
573
+ )
574
+
575
+ def _maybe_retry(self, key: str, entry: dict[str, Any], pattern_name: str, now: float, out: list[ScheduledJob]) -> None:
576
+ # The buffer is cleared when a job fires, so seeing the same message
577
+ # again means the session is still limited and the send did not stick.
578
+ fired_at = entry.get("fired_at") or 0.0
579
+ if now - fired_at < RETRY_QUIET_SECONDS:
580
+ return
581
+ attempts = int(entry.get("attempts", 0))
582
+ if attempts >= len(RETRY_BACKOFF_SECONDS):
583
+ if not entry.get("gave_up"):
584
+ entry["gave_up"] = True
585
+ log(f"Limit message still visible after {attempts} retries via {pattern_name!r}; giving up.", self.cfg)
586
+ return
587
+
588
+ delay = RETRY_BACKOFF_SECONDS[attempts]
589
+ entry["attempts"] = attempts + 1
590
+ entry["state"] = "pending"
591
+ retry_at = datetime.now().astimezone() + timedelta(seconds=delay)
592
+ job = ScheduledJob(
593
+ timestamp=int(now + delay),
594
+ reset_at=retry_at,
595
+ pattern_name=f"{pattern_name} (retry {attempts + 1})",
596
+ text_key=key,
597
+ )
598
+ self.jobs.append(job)
599
+ out.append(job)
600
+ log(f"Limit message still visible after auto-send; retrying in {delay:.0f}s (attempt {attempts + 1}).", self.cfg)
601
+
602
+ def mark_fired(self, job: ScheduledJob) -> None:
603
+ entry = self.entries.get(job.text_key)
604
+ if entry is not None:
605
+ entry["state"] = "fired"
606
+ entry["fired_at"] = time.time()
607
+ # Drop scrollback so the handled message cannot be re-parsed later
608
+ # (once its time is past it would otherwise re-schedule for tomorrow).
609
+ self.buffer = ""
610
+
611
+ def mark_failed(self, job: ScheduledJob) -> None:
612
+ entry = self.entries.get(job.text_key)
613
+ if entry is not None:
614
+ entry["state"] = "failed"
615
+
616
+ def pop_due_jobs(self, now: Optional[float] = None) -> list[ScheduledJob]:
617
+ if now is None:
618
+ now = time.time()
619
+ due: list[ScheduledJob] = []
620
+ while self.jobs and self.jobs[0].timestamp <= now:
621
+ due.append(self.jobs.pop(0))
622
+ return due
623
+
624
+ def next_timeout(self, default: float = 0.25, grace: float = 0.0) -> float:
625
+ if not self.jobs:
626
+ return default
627
+ return min(default, max(0.0, self.jobs[0].timestamp + grace - time.time()))
628
+
629
+
630
+ def copy_winsize(src_fd: int, dst_fd: int) -> None:
631
+ try:
632
+ size = fcntl.ioctl(src_fd, termios.TIOCGWINSZ, b"\0" * 8)
633
+ fcntl.ioctl(dst_fd, termios.TIOCSWINSZ, size)
634
+ except Exception:
635
+ pass
636
+
637
+
638
+ def status_to_exit_code(status: Optional[int]) -> int:
639
+ if status is None:
640
+ return 0
641
+ if os.WIFEXITED(status):
642
+ return os.WEXITSTATUS(status)
643
+ if os.WIFSIGNALED(status):
644
+ return 128 + os.WTERMSIG(status)
645
+ return 1
646
+
647
+
648
+ def send_message_to_fd(master_fd: int, cfg: dict[str, Any]) -> bool:
649
+ """Type the configured message into the live session.
650
+
651
+ Returns False when the write fails (the wrapped process is likely gone),
652
+ so the caller can hand the job to the fallback path instead.
653
+ """
654
+ msg = load_message(cfg)
655
+ if not msg:
656
+ log("Auto-send skipped because the configured message is empty.", cfg)
657
+ return True
658
+ try:
659
+ # \r is what a real Enter keypress sends; raw-mode TUIs expect it and
660
+ # canonical-mode programs receive it as \n via ICRNL.
661
+ os.write(master_fd, msg.encode("utf-8") + b"\r")
662
+ except OSError as exc:
663
+ log(f"Auto-send failed; the wrapped process is likely gone ({exc}).", cfg)
664
+ return False
665
+ log("Auto-sent configured message to live session.", cfg)
666
+ return True
667
+
668
+
669
+ def fallback_disabled(invocation: RunnerInvocation) -> bool:
670
+ if not invocation.fallback_allowed:
671
+ return True
672
+ value = os.environ.get("AUTOLIMIT_DISABLE_FALLBACK", "0")
673
+ return value in {"1", "true", "True", "yes", "YES"}
674
+
675
+
676
+ def profile_for_runner(cfg: dict[str, Any], runner_name: str) -> dict[str, Any]:
677
+ runners = cfg.get("runners")
678
+ if not isinstance(runners, dict):
679
+ return {}
680
+ exact = runners.get(runner_name)
681
+ if isinstance(exact, dict):
682
+ return exact
683
+ lower = runners.get(runner_name.lower())
684
+ if isinstance(lower, dict):
685
+ return lower
686
+ return {}
687
+
688
+
689
+ def expand_fallback_template(template: Any, invocation: RunnerInvocation, message: str) -> list[str]:
690
+ runner = invocation.command[0]
691
+ args = invocation.command[1:]
692
+
693
+ if isinstance(template, str):
694
+ # String templates are split after expansion. Prefer list templates in
695
+ # config.json when arguments may contain whitespace.
696
+ rendered = template.replace("{runner}", runner).replace("{message}", message)
697
+ rendered = rendered.replace("{args}", shell_join(args))
698
+ return shlex.split(rendered)
699
+
700
+ if not isinstance(template, list):
701
+ return []
702
+
703
+ out: list[str] = []
704
+ for item in template:
705
+ if not isinstance(item, str):
706
+ item = str(item)
707
+ if item == "{args}":
708
+ out.extend(args)
709
+ continue
710
+ if item == "{command}":
711
+ out.extend(invocation.command)
712
+ continue
713
+ out.append(item.replace("{runner}", runner).replace("{message}", message))
714
+ return out
715
+
716
+
717
+ def shell_path() -> str:
718
+ raw = os.environ.get("AUTOLIMIT_SHELL") or os.environ.get("SHELL") or "/bin/zsh"
719
+ if raw and Path(raw).exists():
720
+ return raw
721
+ for candidate in ("/bin/zsh", "/bin/bash", "/bin/sh"):
722
+ if Path(candidate).exists():
723
+ return candidate
724
+ return "/bin/sh"
725
+
726
+
727
+ def shell_argv_for_command(argv: list[str]) -> list[str]:
728
+ shell = shell_path()
729
+ command_line = shell_join(argv)
730
+ # Separate -i and -c works for zsh, bash, fish, and POSIX-ish sh.
731
+ return [shell, "-i", "-c", command_line]
732
+
733
+
734
+ def child_exec_argv(invocation: RunnerInvocation) -> list[str]:
735
+ if invocation.mode == "direct":
736
+ return invocation.command
737
+ return shell_argv_for_command(invocation.command)
738
+
739
+
740
+ def fallback_after_exit(job: ScheduledJob, invocation: RunnerInvocation, cfg: dict[str, Any]) -> int:
741
+ if fallback_disabled(invocation):
742
+ log("Wrapped process exited before reset; fallback is disabled.", cfg)
743
+ return 0
744
+
745
+ profile = profile_for_runner(cfg, invocation.runner_name)
746
+ template = os.environ.get("AUTOLIMIT_FALLBACK") or profile.get("fallback")
747
+ if not template:
748
+ reset_local = job.reset_at.isoformat(timespec="seconds")
749
+ print(
750
+ f"\n[autolimit] The wrapped process exited before reset ({reset_local}). "
751
+ f"No fallback is configured for runner {invocation.runner_name!r}.",
752
+ file=sys.stderr,
753
+ )
754
+ print(
755
+ "[autolimit] Live auto-send only works while the process is still running. "
756
+ "Add a fallback in ~/.config/autolimit/config.json if this runner can resume non-interactively.",
757
+ file=sys.stderr,
758
+ )
759
+ log(f"No fallback configured for runner {invocation.runner_name!r}; skipped.", cfg)
760
+ return 0
761
+
762
+ delay = max(0.0, job.timestamp - time.time() + send_grace_seconds(cfg))
763
+ reset_local = job.reset_at.isoformat(timespec="seconds")
764
+
765
+ max_wait = max(
766
+ 0.0,
767
+ config_float(cfg, "max_fallback_wait_hours", "AUTOLIMIT_MAX_FALLBACK_WAIT_HOURS", DEFAULT_MAX_FALLBACK_WAIT_HOURS)
768
+ * 3600.0,
769
+ )
770
+ if delay > max_wait:
771
+ print(
772
+ f"\n[autolimit] Reset at {reset_local} is {delay / 3600.0:.1f}h away, beyond "
773
+ f"max_fallback_wait_hours ({max_wait / 3600.0:g}h); not waiting for fallback.",
774
+ file=sys.stderr,
775
+ )
776
+ log(f"Fallback skipped; reset is {delay / 3600.0:.1f}h away (max {max_wait / 3600.0:g}h).", cfg)
777
+ return 0
778
+
779
+ print(f"\n[autolimit] Wrapped process exited before reset. Waiting until {reset_local} before fallback. Press Ctrl-C to abort.", file=sys.stderr)
780
+ log(f"Wrapped process exited before reset; waiting {delay:.1f}s for fallback.", cfg)
781
+ try:
782
+ time.sleep(delay)
783
+ except KeyboardInterrupt:
784
+ print("\n[autolimit] Fallback wait interrupted; exiting without running the fallback.", file=sys.stderr)
785
+ log("Fallback wait interrupted by user.", cfg)
786
+ return 130
787
+
788
+ msg = load_message(cfg)
789
+ if not msg:
790
+ print("[autolimit] Configured message is empty; fallback skipped.", file=sys.stderr)
791
+ log("Fallback skipped because the configured message is empty.", cfg)
792
+ return 0
793
+
794
+ fallback_cmd = expand_fallback_template(template, invocation, msg)
795
+ if not fallback_cmd:
796
+ print(f"[autolimit] Empty fallback for runner {invocation.runner_name!r}; skipped.", file=sys.stderr)
797
+ log(f"Empty fallback for runner {invocation.runner_name!r}; skipped.", cfg)
798
+ return 0
799
+
800
+ display = shell_join(fallback_cmd[:-1] + ["<message>"]) if fallback_cmd[-1] == msg else shell_join(fallback_cmd)
801
+ print(f"\n[autolimit] Running fallback: {display}\n", file=sys.stderr)
802
+ log("Running fallback command after reset: " + display, cfg)
803
+
804
+ try:
805
+ if invocation.mode == "direct":
806
+ return subprocess.run(fallback_cmd).returncode
807
+ return subprocess.run(shell_argv_for_command(fallback_cmd)).returncode
808
+ except FileNotFoundError:
809
+ print(f"[autolimit] Command not found: {fallback_cmd[0]}", file=sys.stderr)
810
+ log(f"Fallback failed; command not found: {fallback_cmd[0]}", cfg)
811
+ return 127
812
+ except KeyboardInterrupt:
813
+ print("\n[autolimit] Fallback interrupted.", file=sys.stderr)
814
+ log("Fallback command interrupted by user.", cfg)
815
+ return 130
816
+
817
+
818
+ def select_exit_job(
819
+ pending: list[ScheduledJob],
820
+ failed_sends: list[ScheduledJob],
821
+ last_sent_job: Optional[ScheduledJob],
822
+ last_sent_at: float,
823
+ now: Optional[float] = None,
824
+ ) -> Optional[ScheduledJob]:
825
+ """Pick the job the fallback should handle after the wrapped process exits.
826
+
827
+ Pending jobs and failed live sends always qualify. A live send issued just
828
+ before the exit also qualifies: the process was likely already dead or
829
+ dying, so the message was never processed.
830
+ """
831
+ if now is None:
832
+ now = time.time()
833
+ candidates = list(pending) + list(failed_sends)
834
+ if not candidates and last_sent_job is not None and now - last_sent_at <= SEND_CONFIRM_SECONDS:
835
+ candidates = [last_sent_job]
836
+ if not candidates:
837
+ return None
838
+ return min(candidates, key=lambda job: job.timestamp)
839
+
840
+
841
+ def parse_invocation(argv: list[str]) -> tuple[Optional[RunnerInvocation], dict[str, Any]]:
842
+ opts: dict[str, Any] = {
843
+ "dry_run": False,
844
+ "mode": os.environ.get("AUTOLIMIT_EXEC_MODE", "shell").lower(),
845
+ "fallback_allowed": True,
846
+ }
847
+
848
+ if opts["mode"] not in {"shell", "direct"}:
849
+ opts["mode"] = "shell"
850
+
851
+ rest = list(argv)
852
+ while rest and rest[0] in KNOWN_WRAPPER_FLAGS:
853
+ flag = rest.pop(0)
854
+ if flag == "--direct":
855
+ opts["mode"] = "direct"
856
+ elif flag == "--shell":
857
+ opts["mode"] = "shell"
858
+ elif flag == "--no-fallback":
859
+ opts["fallback_allowed"] = False
860
+ elif flag == "--dry-run":
861
+ opts["dry_run"] = True
862
+
863
+ if not rest:
864
+ return None, opts
865
+
866
+ if rest[0] == "--":
867
+ command = rest[1:]
868
+ if not command:
869
+ raise AutolimitError("No command was provided after --.")
870
+ elif rest[0].startswith("--") and rest[0] not in RESERVED_COMMANDS:
871
+ runner = rest[0][2:]
872
+ if not runner:
873
+ raise AutolimitError("Empty runner name.")
874
+ if "=" in runner:
875
+ raise AutolimitError("Runner selector cannot contain '='. Use `autolimit -- <command> ...` instead.")
876
+ command = [runner] + rest[1:]
877
+ elif rest[0].startswith("-"):
878
+ raise AutolimitError(f"Unknown autolimit option: {rest[0]}")
879
+ else:
880
+ command = rest
881
+
882
+ runner_name = Path(command[0]).name
883
+ invocation = RunnerInvocation(
884
+ command=command,
885
+ runner_name=runner_name,
886
+ mode=opts["mode"],
887
+ fallback_allowed=bool(opts["fallback_allowed"]),
888
+ )
889
+ return invocation, opts
890
+
891
+
892
+ def run_pty(invocation: RunnerInvocation, cfg: dict[str, Any]) -> int:
893
+ if not os.isatty(sys.stdin.fileno()) or not os.isatty(sys.stdout.fileno()):
894
+ print("[autolimit] stdin and stdout must be terminals. Run autolimit from an interactive terminal.", file=sys.stderr)
895
+ return 2
896
+
897
+ child_argv = child_exec_argv(invocation)
898
+ log(
899
+ f"Starting runner={invocation.runner_name!r}, mode={invocation.mode}, command={shell_join(invocation.command)}",
900
+ cfg,
901
+ )
902
+
903
+ stdin_fd = sys.stdin.fileno()
904
+ stdout_fd = sys.stdout.fileno()
905
+
906
+ try:
907
+ old_tty = termios.tcgetattr(stdin_fd)
908
+ except termios.error as exc:
909
+ print(f"[autolimit] Failed to read terminal attributes: {exc}", file=sys.stderr)
910
+ return 2
911
+
912
+ pid, master_fd = pty.fork()
913
+
914
+ if pid == 0:
915
+ try:
916
+ os.execvp(child_argv[0], child_argv)
917
+ except FileNotFoundError:
918
+ os.write(2, f"[autolimit] Command not found: {child_argv[0]}\n".encode("utf-8"))
919
+ os._exit(127)
920
+ except Exception as exc:
921
+ os.write(2, f"[autolimit] Failed to exec {child_argv[0]}: {exc}\n".encode("utf-8"))
922
+ os._exit(126)
923
+
924
+ scanner = LimitScanner(cfg)
925
+ child_status: Optional[int] = None
926
+ child_alive = True
927
+ failed_sends: list[ScheduledJob] = []
928
+ last_sent_job: Optional[ScheduledJob] = None
929
+ last_sent_at = 0.0
930
+
931
+ def on_winch(signum, frame): # noqa: ARG001 - signal handler signature
932
+ copy_winsize(stdin_fd, master_fd)
933
+ try:
934
+ os.kill(pid, signal.SIGWINCH)
935
+ except ProcessLookupError:
936
+ pass
937
+
938
+ old_winch = signal.getsignal(signal.SIGWINCH)
939
+ signal.signal(signal.SIGWINCH, on_winch)
940
+ copy_winsize(stdin_fd, master_fd)
941
+
942
+ try:
943
+ tty.setraw(stdin_fd)
944
+
945
+ while child_alive:
946
+ grace = send_grace_seconds(cfg)
947
+ due_cutoff = time.time() - grace
948
+ for job in scanner.pop_due_jobs(now=due_cutoff):
949
+ if send_message_to_fd(master_fd, cfg):
950
+ scanner.mark_fired(job)
951
+ last_sent_job, last_sent_at = job, time.time()
952
+ else:
953
+ scanner.mark_failed(job)
954
+ failed_sends.append(job)
955
+
956
+ timeout = scanner.next_timeout(default=0.25, grace=grace)
957
+
958
+ try:
959
+ rlist, _, _ = select.select([stdin_fd, master_fd], [], [], timeout)
960
+ except InterruptedError:
961
+ continue
962
+
963
+ if stdin_fd in rlist:
964
+ try:
965
+ data = os.read(stdin_fd, 4096)
966
+ except OSError:
967
+ data = b""
968
+ if data:
969
+ os.write(master_fd, data)
970
+ else:
971
+ try:
972
+ os.kill(pid, signal.SIGHUP)
973
+ except ProcessLookupError:
974
+ pass
975
+ child_alive = False
976
+ break
977
+
978
+ if master_fd in rlist:
979
+ try:
980
+ data = os.read(master_fd, 4096)
981
+ except OSError as exc:
982
+ if exc.errno == errno.EIO:
983
+ child_alive = False
984
+ break
985
+ raise
986
+
987
+ if not data:
988
+ child_alive = False
989
+ break
990
+
991
+ os.write(stdout_fd, data)
992
+ scanner.feed(data)
993
+
994
+ try:
995
+ done_pid, status = os.waitpid(pid, os.WNOHANG)
996
+ except ChildProcessError:
997
+ done_pid, status = pid, child_status or 0
998
+ if done_pid == pid:
999
+ child_status = status
1000
+ child_alive = False
1001
+ break
1002
+
1003
+ # Drain output still buffered in the PTY at exit so a limit message
1004
+ # printed just before the process died is displayed and scanned.
1005
+ drain_deadline = time.time() + 1.0
1006
+ while time.time() < drain_deadline:
1007
+ try:
1008
+ rlist, _, _ = select.select([master_fd], [], [], 0.05)
1009
+ except (InterruptedError, OSError):
1010
+ break
1011
+ if master_fd not in rlist:
1012
+ break
1013
+ try:
1014
+ data = os.read(master_fd, 4096)
1015
+ except OSError:
1016
+ break
1017
+ if not data:
1018
+ break
1019
+ os.write(stdout_fd, data)
1020
+ scanner.feed(data)
1021
+
1022
+ finally:
1023
+ try:
1024
+ termios.tcsetattr(stdin_fd, termios.TCSAFLUSH, old_tty)
1025
+ except Exception:
1026
+ pass
1027
+ try:
1028
+ signal.signal(signal.SIGWINCH, old_winch)
1029
+ except Exception:
1030
+ pass
1031
+ try:
1032
+ os.close(master_fd)
1033
+ except Exception:
1034
+ pass
1035
+
1036
+ if child_status is None:
1037
+ try:
1038
+ _, child_status = os.waitpid(pid, 0)
1039
+ except ChildProcessError:
1040
+ child_status = 0
1041
+
1042
+ exit_job = select_exit_job(scanner.jobs, failed_sends, last_sent_job, last_sent_at)
1043
+ if exit_job is not None:
1044
+ log(f"Wrapped process exited with an unresolved limit job ({exit_job.pattern_name}); using fallback path.", cfg)
1045
+ return fallback_after_exit(exit_job, invocation, cfg)
1046
+
1047
+ exit_code = status_to_exit_code(child_status)
1048
+ log(f"Wrapped command exited with status {exit_code}.", cfg)
1049
+ return exit_code
1050
+
1051
+
1052
+ def print_help() -> None:
1053
+ print(
1054
+ """autolimit - wrap any interactive CLI runner and auto-send a message after a reset-time limit message.
1055
+
1056
+ Usage:
1057
+ autolimit --cc [args...] Wrap command `cc [args...]`
1058
+ autolimit --claude [args...] Wrap command `claude [args...]`
1059
+ autolimit <command> [args...] Wrap an explicit command
1060
+ autolimit -- <command> [args...] Wrap a command when you want exact parsing
1061
+
1062
+ Wrapper options, placed before the runner selector:
1063
+ --shell Run through your interactive shell so aliases/functions work. Default.
1064
+ --direct Exec the command directly without shell alias expansion.
1065
+ --no-fallback Disable fallback after the wrapped process exits.
1066
+ --dry-run Show the resolved invocation and exit.
1067
+
1068
+ Utility commands:
1069
+ --init Create ~/.config/autolimit/config.json and message.txt
1070
+ --show-config Print resolved config paths and default runner profiles
1071
+ --list-runners Print runner profiles that have configured fallback commands
1072
+ --test-parser Test the default limit-message parser
1073
+ --version Print version
1074
+
1075
+ Examples:
1076
+ autolimit --cc
1077
+ autolimit --cc --model sonnet
1078
+ autolimit --claude --dangerously-skip-permissions
1079
+ autolimit --direct --python3 my_repl.py
1080
+ autolimit -- npx some-runner --flag
1081
+
1082
+ The message sent after reset is read from ~/.config/autolimit/message.txt by default.
1083
+ """.rstrip()
1084
+ )
1085
+
1086
+
1087
+ def print_config(cfg: dict[str, Any]) -> None:
1088
+ ensure_default_config()
1089
+ print(f"autolimit {VERSION}")
1090
+ print(f"config dir: {CONFIG_DIR}")
1091
+ print(f"config file: {CONFIG_FILE}")
1092
+ print(f"message file: {configured_message_file(cfg)}")
1093
+ print(f"log file: {expand_path(cfg.get('log_file'), LOG_FILE)}")
1094
+ print(f"default tz: {default_tz_name(cfg)}")
1095
+ print(f"grace seconds: {send_grace_seconds(cfg):g}")
1096
+ max_ahead = config_float(cfg, "max_schedule_ahead_hours", "AUTOLIMIT_MAX_SCHEDULE_AHEAD_HOURS", DEFAULT_MAX_SCHEDULE_AHEAD_HOURS)
1097
+ max_wait = config_float(cfg, "max_fallback_wait_hours", "AUTOLIMIT_MAX_FALLBACK_WAIT_HOURS", DEFAULT_MAX_FALLBACK_WAIT_HOURS)
1098
+ print(f"max ahead: {max_ahead:g}h (time-of-day patterns)")
1099
+ print(f"max fb wait: {max_wait:g}h")
1100
+ print(f"exec mode: {os.environ.get('AUTOLIMIT_EXEC_MODE', 'shell')}")
1101
+ print(f"shell: {shell_path()}")
1102
+
1103
+
1104
+ def list_runners(cfg: dict[str, Any]) -> None:
1105
+ runners = cfg.get("runners")
1106
+ if not isinstance(runners, dict) or not runners:
1107
+ print("No runner profiles configured.")
1108
+ return
1109
+ for name in sorted(runners):
1110
+ profile = runners[name]
1111
+ fallback = profile.get("fallback") if isinstance(profile, dict) else None
1112
+ if fallback:
1113
+ print(f"{name}: fallback={fallback}")
1114
+ else:
1115
+ print(f"{name}: no fallback")
1116
+
1117
+
1118
+ def test_parser(cfg: dict[str, Any]) -> int:
1119
+ ensure_default_config()
1120
+ tz = resolve_timezone(default_tz_name(cfg), cfg)
1121
+ now = datetime.now(tz)
1122
+
1123
+ def clock(dt: datetime, ampm: bool = True) -> str:
1124
+ if ampm:
1125
+ hour = dt.hour % 12 or 12
1126
+ return f"{hour}:{dt.minute:02d}{'am' if dt.hour < 12 else 'pm'}"
1127
+ return f"{dt.hour}:{dt.minute:02d}"
1128
+
1129
+ soon = now + timedelta(hours=2)
1130
+ dated = now + timedelta(days=3)
1131
+ month = _MONTHS[dated.month - 1].capitalize()
1132
+ tzn = default_tz_name(cfg)
1133
+
1134
+ # (example line, should it schedule a job?)
1135
+ examples = [
1136
+ (f"You've hit your session limit · resets {clock(soon)} ({tzn})", True),
1137
+ (f"You’ve hit your usage limit - resets {clock(soon)} (KST)", True),
1138
+ (f"You've hit your session limit · resets {clock(soon, ampm=False)} ({tzn})", True),
1139
+ (f"You've hit your weekly limit · resets {month} {dated.day} at {clock(dated)} ({tzn})", True),
1140
+ (f"You've hit your session limit · resets {clock(now - timedelta(hours=3))} ({tzn})", False),
1141
+ ]
1142
+
1143
+ ok = True
1144
+ for line, should_schedule in examples:
1145
+ jobs = LimitScanner(cfg).feed(line.encode("utf-8"))
1146
+ if should_schedule and len(jobs) == 1:
1147
+ print(f"OK: {line} -> {jobs[0].reset_at.isoformat(timespec='seconds')} via {jobs[0].pattern_name}")
1148
+ elif not should_schedule and not jobs:
1149
+ print(f"OK: {line} -> ignored as stale")
1150
+ else:
1151
+ print(f"FAIL: {line} -> {[job.reset_at.isoformat(timespec='seconds') for job in jobs]}", file=sys.stderr)
1152
+ ok = False
1153
+ return 0 if ok else 1
1154
+
1155
+
1156
+ def dry_run(invocation: RunnerInvocation, cfg: dict[str, Any]) -> int:
1157
+ print("Resolved autolimit invocation")
1158
+ print(f"runner: {invocation.runner_name}")
1159
+ print(f"mode: {invocation.mode}")
1160
+ print(f"command: {shell_join(invocation.command)}")
1161
+ print(f"child exec argv: {shell_join(child_exec_argv(invocation))}")
1162
+ profile = profile_for_runner(cfg, invocation.runner_name)
1163
+ fallback = profile.get("fallback") if profile else None
1164
+ print(f"fallback: {fallback if fallback else '(none configured)'}")
1165
+ return 0
1166
+
1167
+
1168
+ def main(argv: list[str]) -> int:
1169
+ if sys.version_info < (3, 9):
1170
+ print("[autolimit] Python 3.9+ is required.", file=sys.stderr)
1171
+ return 2
1172
+
1173
+ # Handle help/version before config initialization where possible.
1174
+ if argv and argv[0] in {"--help", "-h"}:
1175
+ print_help()
1176
+ return 0
1177
+ if argv and argv[0] in {"--version", "-V", "--autolimit-version", "--claude-auto-version"}:
1178
+ print(VERSION)
1179
+ return 0
1180
+
1181
+ cfg = load_config()
1182
+
1183
+ if argv and argv[0] == "--init":
1184
+ ensure_default_config()
1185
+ print(f"Created or verified config at {CONFIG_FILE}")
1186
+ print(f"Message file: {configured_message_file(cfg)}")
1187
+ return 0
1188
+ if argv and argv[0] in {"--show-config", "--autolimit-show-config", "--claude-auto-show-config"}:
1189
+ print_config(cfg)
1190
+ return 0
1191
+ if argv and argv[0] == "--list-runners":
1192
+ list_runners(cfg)
1193
+ return 0
1194
+ if argv and argv[0] in {"--test-parser", "--autolimit-test-parser", "--claude-auto-test-parser"}:
1195
+ return test_parser(cfg)
1196
+
1197
+ try:
1198
+ invocation, opts = parse_invocation(argv)
1199
+ except AutolimitError as exc:
1200
+ print(f"[autolimit] {exc}", file=sys.stderr)
1201
+ print("Run `autolimit --help` for usage.", file=sys.stderr)
1202
+ return 2
1203
+
1204
+ if invocation is None:
1205
+ print_help()
1206
+ return 2
1207
+
1208
+ if opts.get("dry_run"):
1209
+ return dry_run(invocation, cfg)
1210
+
1211
+ return run_pty(invocation, cfg)
1212
+
1213
+
1214
+ if __name__ == "__main__":
1215
+ raise SystemExit(main(sys.argv[1:]))