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.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/bin/autolimit.js +52 -0
- package/lib/autolimit.py +1215 -0
- package/package.json +39 -0
- package/scripts/github-star.js +179 -0
- package/scripts/postinstall.js +134 -0
package/lib/autolimit.py
ADDED
|
@@ -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:]))
|