cdx-manager 0.2.1
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 +243 -0
- package/bin/cdx +20 -0
- package/changelogs/CHANGELOGS_0_1_1.md +98 -0
- package/changelogs/CHANGELOGS_0_2_0.md +68 -0
- package/changelogs/CHANGELOGS_0_2_1.md +29 -0
- package/package.json +44 -0
- package/src/__init__.py +17 -0
- package/src/claude_refresh.py +72 -0
- package/src/claude_usage.py +84 -0
- package/src/cli.py +188 -0
- package/src/cli_commands.py +369 -0
- package/src/cli_render.py +131 -0
- package/src/config.py +8 -0
- package/src/errors.py +4 -0
- package/src/health.py +125 -0
- package/src/notify.py +138 -0
- package/src/provider_runtime.py +290 -0
- package/src/repair.py +121 -0
- package/src/session_service.py +563 -0
- package/src/session_store.py +244 -0
- package/src/status_source.py +572 -0
- package/src/status_view.py +270 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
|
|
8
|
+
_ANSI_TERMINAL_CONTROL = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
9
|
+
_OSC_SEQUENCE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
10
|
+
_CONTROL_CHAR = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
11
|
+
|
|
12
|
+
MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
13
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
14
|
+
MAX_STATUS_READ_BYTES = 512 * 1024
|
|
15
|
+
MAX_STATUS_CANDIDATE_FILES = 64
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _strip_ansi(text):
|
|
19
|
+
return _ANSI_ESCAPE.sub("", str(text or ""))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_terminal_transcript(text):
|
|
23
|
+
text = str(text or "")
|
|
24
|
+
text = _OSC_SEQUENCE.sub(" ", text)
|
|
25
|
+
text = _ANSI_TERMINAL_CONTROL.sub(" ", text)
|
|
26
|
+
text = _ANSI_ESCAPE.sub(" ", text)
|
|
27
|
+
text = _CONTROL_CHAR.sub(" ", text)
|
|
28
|
+
text = text.replace("\r", "\n")
|
|
29
|
+
return text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_read_text(file_path, max_bytes=MAX_STATUS_READ_BYTES):
|
|
33
|
+
try:
|
|
34
|
+
size = os.path.getsize(file_path)
|
|
35
|
+
with open(file_path, "rb") as f:
|
|
36
|
+
if size > max_bytes:
|
|
37
|
+
f.seek(-max_bytes, os.SEEK_END)
|
|
38
|
+
return f.read().decode("utf-8", errors="replace")
|
|
39
|
+
except (FileNotFoundError, OSError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _safe_stat(file_path):
|
|
44
|
+
try:
|
|
45
|
+
return os.stat(file_path)
|
|
46
|
+
except OSError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _collect_text_values(value, output=None):
|
|
51
|
+
if output is None:
|
|
52
|
+
output = []
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
output.append(value)
|
|
55
|
+
elif isinstance(value, list):
|
|
56
|
+
for item in value:
|
|
57
|
+
_collect_text_values(item, output)
|
|
58
|
+
elif isinstance(value, dict):
|
|
59
|
+
for item in value.values():
|
|
60
|
+
_collect_text_values(item, output)
|
|
61
|
+
return output
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_status_blocks_from_text(text, provider=None, source_ref=None, timestamp=None):
|
|
65
|
+
normalized = _normalize_terminal_transcript(text)
|
|
66
|
+
lines = normalized.split("\n")
|
|
67
|
+
items = []
|
|
68
|
+
|
|
69
|
+
def collect_blocks(
|
|
70
|
+
start_pattern,
|
|
71
|
+
end_patterns,
|
|
72
|
+
max_span=80,
|
|
73
|
+
pre_context=0,
|
|
74
|
+
context_pattern=None,
|
|
75
|
+
context_stop_patterns=None,
|
|
76
|
+
):
|
|
77
|
+
blocks = []
|
|
78
|
+
index = 0
|
|
79
|
+
while index < len(lines):
|
|
80
|
+
if not start_pattern.search(lines[index]):
|
|
81
|
+
index += 1
|
|
82
|
+
continue
|
|
83
|
+
end_index = len(lines)
|
|
84
|
+
for cursor in range(index + 1, min(len(lines), index + max_span)):
|
|
85
|
+
if any(pattern.search(lines[cursor]) for pattern in end_patterns):
|
|
86
|
+
end_index = cursor
|
|
87
|
+
break
|
|
88
|
+
start_index = max(0, index - pre_context)
|
|
89
|
+
if context_pattern is not None:
|
|
90
|
+
cursor = index - 1
|
|
91
|
+
while cursor >= 0:
|
|
92
|
+
line = lines[cursor]
|
|
93
|
+
if context_stop_patterns and any(pattern.search(line) for pattern in context_stop_patterns):
|
|
94
|
+
break
|
|
95
|
+
if not context_pattern.search(line):
|
|
96
|
+
break
|
|
97
|
+
start_index = cursor
|
|
98
|
+
cursor -= 1
|
|
99
|
+
block = "\n".join(lines[start_index:end_index]).strip()
|
|
100
|
+
if block:
|
|
101
|
+
blocks.append(block)
|
|
102
|
+
index = max(index + 1, end_index)
|
|
103
|
+
return blocks
|
|
104
|
+
|
|
105
|
+
if provider != "codex":
|
|
106
|
+
for block in collect_blocks(
|
|
107
|
+
re.compile(r"^\s*(?:[│|]\s*)?Current session\b", re.I),
|
|
108
|
+
[re.compile(p, re.I) for p in [
|
|
109
|
+
r"^Extra usage\b", r"^Esc to cancel\b",
|
|
110
|
+
r"^To continue this session\b", r"^╰",
|
|
111
|
+
]],
|
|
112
|
+
):
|
|
113
|
+
items.append({"source_ref": source_ref, "timestamp": timestamp, "text": block})
|
|
114
|
+
|
|
115
|
+
if provider != "claude":
|
|
116
|
+
for block in collect_blocks(
|
|
117
|
+
re.compile(r"^\s*(?:[│|]\s*)?5h\s+limit\b", re.I),
|
|
118
|
+
[re.compile(p, re.I) for p in [
|
|
119
|
+
r"^To continue this session\b", r"^╰",
|
|
120
|
+
]],
|
|
121
|
+
context_pattern=re.compile(
|
|
122
|
+
r"^\s*$|^\s*(?:[│|]\s*)?(?:╭|Visit\b|information\b|Model:|Directory:|Permissions:|Agents\.md:|Account:|Collaboration mode:|Session:)",
|
|
123
|
+
re.I,
|
|
124
|
+
),
|
|
125
|
+
context_stop_patterns=[
|
|
126
|
+
re.compile(r"^\s*(?:[│|]\s*)?5h\s+limit\b", re.I),
|
|
127
|
+
re.compile(r"^\s*(?:[│|]\s*)?Weekly\s+limit\b", re.I),
|
|
128
|
+
re.compile(r"^To continue this session\b", re.I),
|
|
129
|
+
],
|
|
130
|
+
):
|
|
131
|
+
items.append({"source_ref": source_ref, "timestamp": timestamp, "text": block})
|
|
132
|
+
|
|
133
|
+
if items:
|
|
134
|
+
return items
|
|
135
|
+
|
|
136
|
+
if provider:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
keyword_re = re.compile(r"/status|usage|current|remaining|\d{1,3}%", re.I)
|
|
140
|
+
fallback_lines = str(text or "").splitlines()
|
|
141
|
+
for i in range(len(fallback_lines) - 1, -1, -1):
|
|
142
|
+
if not keyword_re.search(fallback_lines[i]):
|
|
143
|
+
continue
|
|
144
|
+
start = max(0, i - 4)
|
|
145
|
+
end = min(len(fallback_lines), i + 5)
|
|
146
|
+
snippet = "\n".join(fallback_lines[start:end]).strip()
|
|
147
|
+
if snippet:
|
|
148
|
+
return [{"source_ref": source_ref, "timestamp": timestamp, "text": snippet}]
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_jsonl_texts(file_path, provider=None):
|
|
153
|
+
text = _safe_read_text(file_path)
|
|
154
|
+
if not text:
|
|
155
|
+
return []
|
|
156
|
+
items = []
|
|
157
|
+
for line_index, line in enumerate(text.splitlines()):
|
|
158
|
+
line = line.strip()
|
|
159
|
+
if not line:
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
record = json.loads(line)
|
|
163
|
+
payload_texts = _collect_text_values(record.get("payload") or {})
|
|
164
|
+
for candidate in payload_texts:
|
|
165
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
166
|
+
items.extend(_extract_status_blocks_from_text(
|
|
167
|
+
candidate,
|
|
168
|
+
provider=provider,
|
|
169
|
+
source_ref=f"{file_path}:{line_index + 1}",
|
|
170
|
+
timestamp=record.get("timestamp"),
|
|
171
|
+
))
|
|
172
|
+
except (json.JSONDecodeError, AttributeError):
|
|
173
|
+
continue
|
|
174
|
+
return items
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_log_block(file_path, provider=None):
|
|
178
|
+
text = _safe_read_text(file_path)
|
|
179
|
+
if not text:
|
|
180
|
+
return []
|
|
181
|
+
return _extract_status_blocks_from_text(text, provider=provider, source_ref=file_path, timestamp=None)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_month_index(name):
|
|
185
|
+
lower = name[:3].lower()
|
|
186
|
+
for i, m in enumerate(MONTH_ABBR):
|
|
187
|
+
if m.lower() == lower:
|
|
188
|
+
return i
|
|
189
|
+
return -1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _infer_reset_year(month, day):
|
|
193
|
+
now = datetime.now().astimezone()
|
|
194
|
+
year = now.year
|
|
195
|
+
try:
|
|
196
|
+
candidate = datetime(
|
|
197
|
+
year,
|
|
198
|
+
month + 1,
|
|
199
|
+
day,
|
|
200
|
+
tzinfo=now.tzinfo,
|
|
201
|
+
)
|
|
202
|
+
except ValueError:
|
|
203
|
+
return year
|
|
204
|
+
two_days_ago = datetime.fromtimestamp(now.timestamp() - 2 * 24 * 3600, tz=now.tzinfo)
|
|
205
|
+
return year + 1 if candidate < two_days_ago else year
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _normalize_reset_date(raw):
|
|
209
|
+
if not raw:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
raw = str(raw).strip()
|
|
213
|
+
|
|
214
|
+
def pad(n):
|
|
215
|
+
return str(n).zfill(2)
|
|
216
|
+
|
|
217
|
+
def format_time(hours, minutes):
|
|
218
|
+
return f"{pad(hours)}:{pad(minutes)}"
|
|
219
|
+
|
|
220
|
+
def parse_ampm(hours, minutes, meridiem):
|
|
221
|
+
normalized = meridiem.lower().replace(".", "")
|
|
222
|
+
if normalized == "pm" and hours != 12:
|
|
223
|
+
hours += 12
|
|
224
|
+
if normalized == "am" and hours == 12:
|
|
225
|
+
hours = 0
|
|
226
|
+
return hours, minutes
|
|
227
|
+
|
|
228
|
+
# Codex: "10:10 on 17 Apr"
|
|
229
|
+
m = re.match(r"(\d{1,2}):(\d{2})\s+on\s+(\d{1,2})\s+([A-Za-z]+)", raw, re.I)
|
|
230
|
+
if m:
|
|
231
|
+
hours, minutes, day, month_str = int(m[1]), int(m[2]), int(m[3]), m[4]
|
|
232
|
+
month = _parse_month_index(month_str)
|
|
233
|
+
if month != -1:
|
|
234
|
+
year = _infer_reset_year(month, day)
|
|
235
|
+
return f"{MONTH_ABBR[month]} {day} {pad(hours)}:{pad(minutes)}"
|
|
236
|
+
|
|
237
|
+
# Claude: "Thursday, April 17 at 5:00 AM" or "April 17, 2026, 5 PM"
|
|
238
|
+
m = re.match(
|
|
239
|
+
r"(?:[A-Za-z]+,\s+)?([A-Za-z]+)\s+(\d{1,2})(?:,\s+(\d{4}))?"
|
|
240
|
+
r"(?:\s*(?:,|at)\s*(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?))?$",
|
|
241
|
+
raw,
|
|
242
|
+
re.I,
|
|
243
|
+
)
|
|
244
|
+
if m:
|
|
245
|
+
month = _parse_month_index(m[1])
|
|
246
|
+
if month != -1:
|
|
247
|
+
day = int(m[2])
|
|
248
|
+
year = int(m[3]) if m[3] else _infer_reset_year(month, day)
|
|
249
|
+
if m[4]:
|
|
250
|
+
hours, minutes = parse_ampm(int(m[4]), int(m[5] or 0), m[6])
|
|
251
|
+
return f"{MONTH_ABBR[month]} {day} {format_time(hours, minutes)}"
|
|
252
|
+
return f"{MONTH_ABBR[month]} {day}"
|
|
253
|
+
|
|
254
|
+
# Claude session reset: "at 5:00 AM", "5:00 AM", or "today at 5 PM"
|
|
255
|
+
m = re.match(r"(?:today\s+)?(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)$", raw, re.I)
|
|
256
|
+
if m:
|
|
257
|
+
hours, minutes = parse_ampm(int(m[1]), int(m[2] or 0), m[3])
|
|
258
|
+
now = datetime.now().astimezone()
|
|
259
|
+
from datetime import timedelta
|
|
260
|
+
candidate = datetime(now.year, now.month, now.day, hours, minutes, tzinfo=now.tzinfo)
|
|
261
|
+
if candidate <= now:
|
|
262
|
+
candidate = candidate + timedelta(days=1)
|
|
263
|
+
return f"{MONTH_ABBR[candidate.month - 1]} {candidate.day} {format_time(hours, minutes)}"
|
|
264
|
+
|
|
265
|
+
# Codex time-only: "21:51"
|
|
266
|
+
m = re.match(r"^(\d{1,2}):(\d{2})$", raw)
|
|
267
|
+
if m:
|
|
268
|
+
hours, minutes = int(m[1]), int(m[2])
|
|
269
|
+
now = datetime.now().astimezone()
|
|
270
|
+
from datetime import timedelta
|
|
271
|
+
candidate = datetime(now.year, now.month, now.day, hours, minutes, tzinfo=now.tzinfo)
|
|
272
|
+
if candidate <= now:
|
|
273
|
+
candidate = candidate + timedelta(days=1)
|
|
274
|
+
return f"{MONTH_ABBR[candidate.month - 1]} {candidate.day} {format_time(hours, minutes)}"
|
|
275
|
+
|
|
276
|
+
# Claude: "Thursday, April 17" or "April 17" or "April 17, 2026"
|
|
277
|
+
m = re.match(r"(?:[A-Za-z]+,\s+)?([A-Za-z]+)\s+(\d{1,2})(?:,\s+(\d{4}))?", raw, re.I)
|
|
278
|
+
if m:
|
|
279
|
+
month = _parse_month_index(m[1])
|
|
280
|
+
if month != -1:
|
|
281
|
+
day = int(m[2])
|
|
282
|
+
year = int(m[3]) if m[3] else _infer_reset_year(month, day)
|
|
283
|
+
return f"{MONTH_ABBR[month]} {day}"
|
|
284
|
+
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_account_identity(text):
|
|
289
|
+
normalized = _normalize_terminal_transcript(text)
|
|
290
|
+
for line in normalized.split("\n"):
|
|
291
|
+
m = re.match(r"^\s*(?:[│|]\s*)?Account:\s*(.+?)\s*$", line, re.I)
|
|
292
|
+
if not m:
|
|
293
|
+
continue
|
|
294
|
+
value = m.group(1).strip().lower()
|
|
295
|
+
if value:
|
|
296
|
+
return value
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _account_matches_expected(block_text, expected_account_email):
|
|
301
|
+
if not expected_account_email:
|
|
302
|
+
return True
|
|
303
|
+
actual = _extract_account_identity(block_text)
|
|
304
|
+
if not actual:
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
expected = str(expected_account_email).strip().lower()
|
|
308
|
+
actual_email = re.split(r"\s|\(", actual, maxsplit=1)[0]
|
|
309
|
+
expected_email = re.split(r"\s|\(", expected, maxsplit=1)[0]
|
|
310
|
+
|
|
311
|
+
if actual_email == expected_email:
|
|
312
|
+
return True
|
|
313
|
+
if actual_email.startswith(expected_email) or expected_email.startswith(actual_email):
|
|
314
|
+
return min(len(actual_email), len(expected_email)) >= 8
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def extract_named_statuses_from_text(text):
|
|
319
|
+
normalized = _normalize_terminal_transcript(text)
|
|
320
|
+
lines = [l.strip() for l in normalized.split("\n") if l.strip()]
|
|
321
|
+
result = {}
|
|
322
|
+
|
|
323
|
+
key_value_patterns = [
|
|
324
|
+
("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
325
|
+
("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
326
|
+
("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
327
|
+
("credits", re.compile(r"credits?\s*[:=]\s*([\d, ]*\d[\d, ]*)\s*(?:credits?)?", re.I)),
|
|
328
|
+
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
329
|
+
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
330
|
+
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
331
|
+
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
332
|
+
("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
333
|
+
("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
334
|
+
("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
335
|
+
("remaining_5h_pct", re.compile(r"remaining\s+5h\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
336
|
+
("remaining_week_pct", re.compile(r"week(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
337
|
+
("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
338
|
+
]
|
|
339
|
+
for field, pattern in key_value_patterns:
|
|
340
|
+
if field not in result:
|
|
341
|
+
m = pattern.search(normalized)
|
|
342
|
+
if m:
|
|
343
|
+
result[field] = int(re.sub(r"\D", "", m[1]))
|
|
344
|
+
|
|
345
|
+
# Claude "Current session / Current week" block
|
|
346
|
+
def extract_following_percent(anchor_pattern):
|
|
347
|
+
idx = next((i for i, l in enumerate(lines) if anchor_pattern.search(l)), -1)
|
|
348
|
+
if idx == -1:
|
|
349
|
+
return None
|
|
350
|
+
for i in range(idx + 1, min(len(lines), idx + 8)):
|
|
351
|
+
m = re.search(r"(\d{1,3})%\s+used", lines[i], re.I)
|
|
352
|
+
if m:
|
|
353
|
+
return int(m[1])
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
session_used = extract_following_percent(re.compile(r"\bCurrent session\b", re.I))
|
|
357
|
+
week_used = extract_following_percent(re.compile(r"\bCurrent week\b", re.I))
|
|
358
|
+
if session_used is not None or week_used is not None:
|
|
359
|
+
if "usage_pct" not in result and session_used is not None:
|
|
360
|
+
result["usage_pct"] = session_used
|
|
361
|
+
if "remaining_5h_pct" not in result and session_used is not None:
|
|
362
|
+
result["remaining_5h_pct"] = max(0, 100 - session_used)
|
|
363
|
+
if "remaining_week_pct" not in result and week_used is not None:
|
|
364
|
+
result["remaining_week_pct"] = max(0, 100 - week_used)
|
|
365
|
+
|
|
366
|
+
# Table header row
|
|
367
|
+
header_idx = next(
|
|
368
|
+
(i for i, l in enumerate(lines)
|
|
369
|
+
if re.search(r"\bSESSION\b", l, re.I)
|
|
370
|
+
and re.search(r"\bUSAGE\b", l, re.I)
|
|
371
|
+
and re.search(r"\b5H\b", l, re.I)
|
|
372
|
+
and re.search(r"\bWEEK\b", l, re.I)),
|
|
373
|
+
-1,
|
|
374
|
+
)
|
|
375
|
+
if header_idx != -1:
|
|
376
|
+
for line in lines[header_idx + 1:]:
|
|
377
|
+
pcts = [int(m) for m in re.findall(r"(\d{1,3})%", line)]
|
|
378
|
+
if len(pcts) >= 3:
|
|
379
|
+
result.setdefault("usage_pct", pcts[0])
|
|
380
|
+
result.setdefault("remaining_5h_pct", pcts[1])
|
|
381
|
+
result.setdefault("remaining_week_pct", pcts[2])
|
|
382
|
+
break
|
|
383
|
+
|
|
384
|
+
for line in lines:
|
|
385
|
+
m = re.search(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", line, re.I)
|
|
386
|
+
if m and "remaining_5h_pct" not in result:
|
|
387
|
+
result["remaining_5h_pct"] = int(m[1])
|
|
388
|
+
m = re.search(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", line, re.I)
|
|
389
|
+
if m and "remaining_5h_pct" not in result:
|
|
390
|
+
result["remaining_5h_pct"] = int(m[1])
|
|
391
|
+
m = re.search(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", line, re.I)
|
|
392
|
+
if m and "remaining_week_pct" not in result:
|
|
393
|
+
result["remaining_week_pct"] = int(m[1])
|
|
394
|
+
m = re.search(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", line, re.I)
|
|
395
|
+
if m and "remaining_week_pct" not in result:
|
|
396
|
+
result["remaining_week_pct"] = int(m[1])
|
|
397
|
+
|
|
398
|
+
if "remaining_5h_pct" in result and "usage_pct" not in result:
|
|
399
|
+
result["usage_pct"] = max(0, 100 - result["remaining_5h_pct"])
|
|
400
|
+
if "remaining_week_pct" in result and "usage_pct" not in result:
|
|
401
|
+
result["usage_pct"] = max(0, 100 - result["remaining_week_pct"])
|
|
402
|
+
|
|
403
|
+
def _extract_reset_near(anchor_pattern, stop_pattern=None, max_span=8):
|
|
404
|
+
idx = next((i for i, l in enumerate(lines) if anchor_pattern.search(l)), -1)
|
|
405
|
+
if idx == -1:
|
|
406
|
+
return None
|
|
407
|
+
for i in range(idx, min(len(lines), idx + max_span)):
|
|
408
|
+
if i > idx and stop_pattern and stop_pattern.search(lines[i]):
|
|
409
|
+
break
|
|
410
|
+
m = re.search(r"\(resets\s+(.+?)\)", lines[i], re.I)
|
|
411
|
+
if m:
|
|
412
|
+
return m[1].strip()
|
|
413
|
+
m = re.match(r"resets?\s*:?\s*(.+)", lines[i], re.I)
|
|
414
|
+
if m:
|
|
415
|
+
return m[1].strip()
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
reset_5h_at = _extract_reset_near(
|
|
419
|
+
re.compile(r"\b5h\s+limit\b", re.I),
|
|
420
|
+
stop_pattern=re.compile(r"\bweekly\s+limit\b", re.I),
|
|
421
|
+
max_span=4,
|
|
422
|
+
)
|
|
423
|
+
if not reset_5h_at:
|
|
424
|
+
reset_5h_at = _extract_reset_near(
|
|
425
|
+
re.compile(r"\bCurrent session\b", re.I),
|
|
426
|
+
stop_pattern=re.compile(r"\bCurrent week\b", re.I),
|
|
427
|
+
)
|
|
428
|
+
reset_week_at = _extract_reset_near(
|
|
429
|
+
re.compile(r"\bweekly\s+limit\b", re.I),
|
|
430
|
+
max_span=4,
|
|
431
|
+
)
|
|
432
|
+
if not reset_week_at:
|
|
433
|
+
reset_week_at = _extract_reset_near(re.compile(r"\bCurrent week\b", re.I))
|
|
434
|
+
|
|
435
|
+
if not reset_week_at:
|
|
436
|
+
for line in lines:
|
|
437
|
+
m = re.match(r"(?:(?:weekly\s+)?resets?)\s*:?\s*(.+)", line, re.I)
|
|
438
|
+
if m:
|
|
439
|
+
reset_week_at = m[1].strip()
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
if not reset_week_at:
|
|
443
|
+
all_resets = list(re.finditer(r"\(resets\s+(.+?)\)", normalized, re.I))
|
|
444
|
+
if all_resets:
|
|
445
|
+
reset_week_at = all_resets[-1].group(1).strip()
|
|
446
|
+
|
|
447
|
+
if reset_5h_at:
|
|
448
|
+
reset_5h_at = _normalize_reset_date(reset_5h_at) or reset_5h_at
|
|
449
|
+
if reset_week_at:
|
|
450
|
+
reset_week_at = _normalize_reset_date(reset_week_at) or reset_week_at
|
|
451
|
+
|
|
452
|
+
if not result:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
"usage_pct": result.get("usage_pct"),
|
|
457
|
+
"remaining_5h_pct": result.get("remaining_5h_pct"),
|
|
458
|
+
"remaining_week_pct": result.get("remaining_week_pct"),
|
|
459
|
+
"credits": result.get("credits"),
|
|
460
|
+
"reset_5h_at": reset_5h_at,
|
|
461
|
+
"reset_week_at": reset_week_at,
|
|
462
|
+
"reset_at": reset_week_at or reset_5h_at,
|
|
463
|
+
"raw_status_text": normalized.strip() or None,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _collect_candidate_files(root_dir):
|
|
468
|
+
priority_candidates = []
|
|
469
|
+
history_candidates = []
|
|
470
|
+
direct = [
|
|
471
|
+
os.path.join(root_dir, "history.jsonl"),
|
|
472
|
+
os.path.join(root_dir, "session_index.jsonl"),
|
|
473
|
+
os.path.join(root_dir, "log", "codex-tui.log"),
|
|
474
|
+
]
|
|
475
|
+
for fp in direct:
|
|
476
|
+
if _safe_stat(fp):
|
|
477
|
+
priority_candidates.append(fp)
|
|
478
|
+
|
|
479
|
+
log_dir = os.path.join(root_dir, "log")
|
|
480
|
+
log_dir_stat = _safe_stat(log_dir)
|
|
481
|
+
if log_dir_stat and os.path.isdir(log_dir):
|
|
482
|
+
for fname in os.listdir(log_dir):
|
|
483
|
+
if fname.startswith("cdx-session") and fname.endswith(".log"):
|
|
484
|
+
fp = os.path.join(log_dir, fname)
|
|
485
|
+
if _safe_stat(fp):
|
|
486
|
+
priority_candidates.append(fp)
|
|
487
|
+
|
|
488
|
+
sessions_dir = os.path.join(root_dir, "sessions")
|
|
489
|
+
if not _safe_stat(sessions_dir):
|
|
490
|
+
return priority_candidates, history_candidates
|
|
491
|
+
|
|
492
|
+
skip = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp"}
|
|
493
|
+
|
|
494
|
+
for dirpath, dirnames, filenames in os.walk(sessions_dir):
|
|
495
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in skip]
|
|
496
|
+
for fname in filenames:
|
|
497
|
+
if fname.endswith(".jsonl") or fname.endswith(".log"):
|
|
498
|
+
history_candidates.append(os.path.join(dirpath, fname))
|
|
499
|
+
|
|
500
|
+
return priority_candidates, history_candidates
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _sort_recent(paths):
|
|
504
|
+
candidate_stats = {
|
|
505
|
+
fp: stat
|
|
506
|
+
for fp, stat in ((candidate, _safe_stat(candidate)) for candidate in set(paths))
|
|
507
|
+
if stat
|
|
508
|
+
}
|
|
509
|
+
return sorted(
|
|
510
|
+
candidate_stats,
|
|
511
|
+
key=lambda fp: candidate_stats[fp].st_mtime,
|
|
512
|
+
reverse=True,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def find_latest_status_artifact(root_dir, provider=None, expected_account_email=None):
|
|
517
|
+
priority_candidates, history_candidates = _collect_candidate_files(root_dir)
|
|
518
|
+
candidates = (
|
|
519
|
+
_sort_recent(priority_candidates)
|
|
520
|
+
+ _sort_recent(history_candidates)[:MAX_STATUS_CANDIDATE_FILES]
|
|
521
|
+
)
|
|
522
|
+
records = []
|
|
523
|
+
for fp in candidates:
|
|
524
|
+
normalized_fp = fp.replace(os.sep, "/")
|
|
525
|
+
if "/sessions/" in normalized_fp and os.path.basename(fp).startswith("rollout"):
|
|
526
|
+
continue
|
|
527
|
+
if fp.endswith(".jsonl"):
|
|
528
|
+
records.extend(_extract_jsonl_texts(fp, provider))
|
|
529
|
+
elif fp.endswith(".log"):
|
|
530
|
+
records.extend(_extract_log_block(fp, provider))
|
|
531
|
+
|
|
532
|
+
best = None
|
|
533
|
+
for candidate in records:
|
|
534
|
+
if provider == "codex" and not _account_matches_expected(
|
|
535
|
+
candidate["text"], expected_account_email
|
|
536
|
+
):
|
|
537
|
+
continue
|
|
538
|
+
parsed = extract_named_statuses_from_text(candidate["text"])
|
|
539
|
+
if not parsed:
|
|
540
|
+
continue
|
|
541
|
+
ts = candidate.get("timestamp")
|
|
542
|
+
try:
|
|
543
|
+
score = float(ts) if ts else 0
|
|
544
|
+
except (TypeError, ValueError):
|
|
545
|
+
score = 0
|
|
546
|
+
src_file = re.sub(r":\d+$", "", candidate["source_ref"])
|
|
547
|
+
stat = _safe_stat(src_file)
|
|
548
|
+
if not score and stat:
|
|
549
|
+
score = stat.st_mtime
|
|
550
|
+
priority = 2 if src_file.endswith(".log") else 1
|
|
551
|
+
|
|
552
|
+
if best is None or (priority, score) >= (best["priority"], best["score"]):
|
|
553
|
+
best = {
|
|
554
|
+
"priority": priority,
|
|
555
|
+
"score": score,
|
|
556
|
+
"source_ref": candidate["source_ref"],
|
|
557
|
+
**parsed,
|
|
558
|
+
}
|
|
559
|
+
if ts:
|
|
560
|
+
try:
|
|
561
|
+
best["updated_at"] = datetime.fromtimestamp(
|
|
562
|
+
float(ts) / 1000, tz=timezone.utc
|
|
563
|
+
).astimezone().isoformat()
|
|
564
|
+
except (TypeError, ValueError):
|
|
565
|
+
pass
|
|
566
|
+
if "updated_at" not in best:
|
|
567
|
+
if stat:
|
|
568
|
+
best["updated_at"] = datetime.fromtimestamp(
|
|
569
|
+
stat.st_mtime, tz=timezone.utc
|
|
570
|
+
).astimezone().isoformat()
|
|
571
|
+
|
|
572
|
+
return best
|