cdx-manager 0.9.16 → 0.11.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.
@@ -1,4 +1,5 @@
1
1
  import base64
2
+ import binascii
2
3
  import json
3
4
  import os
4
5
  import shutil
@@ -29,6 +30,7 @@ RESERVED_SESSION_NAMES = {
29
30
  "configs",
30
31
  "cp",
31
32
  "disable",
33
+ "disk",
32
34
  "doctor",
33
35
  "enable",
34
36
  "export",
@@ -48,6 +50,7 @@ RESERVED_SESSION_NAMES = {
48
50
  "power",
49
51
  "ready",
50
52
  "repair",
53
+ "reset",
51
54
  "resume",
52
55
  "ren",
53
56
  "rename",
@@ -260,6 +263,8 @@ def _normalize_status_payload(payload=None):
260
263
  "remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
261
264
  "remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
262
265
  "credits": payload.get("credits"),
266
+ "reset_credits_available": payload.get("reset_credits_available"),
267
+ "reset_credits": payload.get("reset_credits"),
263
268
  "reset_5h_at": payload.get("reset_5h_at"),
264
269
  "reset_week_at": payload.get("reset_week_at"),
265
270
  "reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
@@ -337,6 +342,8 @@ def _status_has_more_detail(candidate, current):
337
342
  "remaining_5h_pct",
338
343
  "remaining_week_pct",
339
344
  "credits",
345
+ "reset_credits_available",
346
+ "reset_credits",
340
347
  "reset_5h_at",
341
348
  "reset_week_at",
342
349
  "reset_at",
@@ -358,6 +365,8 @@ def _merge_status_payload(current, candidate):
358
365
  "remaining_5h_pct",
359
366
  "remaining_week_pct",
360
367
  "credits",
368
+ "reset_credits_available",
369
+ "reset_credits",
361
370
  "reset_5h_at",
362
371
  "reset_week_at",
363
372
  "reset_at",
@@ -893,6 +902,11 @@ def create_session_service(options=None):
893
902
  # lock; a pre-lock snapshot loses a concurrent setting change.
894
903
  current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
895
904
  launch = {**current, **updates}
905
+ if "power" in updates:
906
+ launch.pop("reasoning_effort", None)
907
+ launch.pop("reasoningEffort", None)
908
+ if "reasoning_effort" in updates:
909
+ launch.pop("power", None)
896
910
  explicit_power = "power" in updates or "reasoning_effort" in updates
897
911
  if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
898
912
  launch["fast"] = False
@@ -915,7 +929,7 @@ def create_session_service(options=None):
915
929
  raise CdxError(f"Unknown session: {name}")
916
930
  if not keys:
917
931
  raise CdxError("At least one launch setting is required.")
918
- allowed = {"power", "permission", "fast", "rtk", "logics", "model", "priority"}
932
+ allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
919
933
  unknown = [key for key in keys if key not in allowed]
920
934
  if unknown:
921
935
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
@@ -1101,6 +1115,8 @@ def create_session_service(options=None):
1101
1115
  "remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
1102
1116
  "remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
1103
1117
  "credits": row_status.get("credits") if row_status else None,
1118
+ "reset_credits_available": row_status.get("reset_credits_available") if row_status else None,
1119
+ "reset_credits": row_status.get("reset_credits") if row_status else None,
1104
1120
  "available_pct": _compute_available_pct(row_status),
1105
1121
  "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
1106
1122
  "reset_week_at": row_status.get("reset_week_at") if row_status else None,
@@ -1206,7 +1222,12 @@ def create_session_service(options=None):
1206
1222
  )] = s
1207
1223
  for future in as_completed(futures):
1208
1224
  s = futures[future]
1209
- resolved = future.result()
1225
+ try:
1226
+ resolved = future.result()
1227
+ except CdxError as error:
1228
+ if not str(error).startswith("Unknown session:"):
1229
+ raise
1230
+ continue
1210
1231
  resolved_by_name[s["name"]] = resolved
1211
1232
  if progress_callback:
1212
1233
  progress_callback({
@@ -1215,7 +1236,7 @@ def create_session_service(options=None):
1215
1236
  "has_status": bool(resolved.get("lastStatus")),
1216
1237
  "cache_hit": cache_hits[s["name"]],
1217
1238
  })
1218
- resolved = [resolved_by_name[s["name"]] for s in sessions]
1239
+ resolved = [resolved_by_name[s["name"]] for s in sessions if s["name"] in resolved_by_name]
1219
1240
 
1220
1241
  def sort_key(s):
1221
1242
  at = s.get("lastStatusAt") or ""
@@ -1326,7 +1347,52 @@ def create_session_service(options=None):
1326
1347
  "bundle_size_bytes": len(bundle_bytes),
1327
1348
  }
1328
1349
 
1329
- def import_bundle(file_path, passphrase=None, session_names=None, force=False, merge=False):
1350
+ def _validate_import_profile_files(payload, imported_sessions):
1351
+ profiles = payload.get("profiles") or {}
1352
+ if not isinstance(profiles, dict):
1353
+ raise CdxError("Bundle contains invalid profile data.")
1354
+ decoded_profiles = {}
1355
+ for session_payload in imported_sessions:
1356
+ name = session_payload["name"]
1357
+ files = []
1358
+ profile_items = profiles.get(name, [])
1359
+ if not isinstance(profile_items, list):
1360
+ raise CdxError(f"Bundle contains invalid profile data for session {name}.")
1361
+ for item in profile_items:
1362
+ if not isinstance(item, dict):
1363
+ raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
1364
+ rel_path = _safe_relpath(item.get("path"))
1365
+ data_b64 = item.get("data_b64")
1366
+ if not isinstance(data_b64, str):
1367
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
1368
+ try:
1369
+ content = base64.b64decode(data_b64.encode("ascii"), validate=True)
1370
+ except (binascii.Error, UnicodeEncodeError) as error:
1371
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
1372
+ files.append({"path": rel_path, "content": content})
1373
+ decoded_profiles[name] = files
1374
+ return decoded_profiles
1375
+
1376
+ def _restore_import_backup(name, backup_root, session_root, old_record, old_state):
1377
+ if os.path.exists(session_root):
1378
+ remove_tree(session_root, ignore_errors=True)
1379
+ if backup_root and os.path.exists(backup_root):
1380
+ os.rename(backup_root, session_root)
1381
+ if old_record:
1382
+ store["replace_session"](name, old_record)
1383
+ if old_state is not None:
1384
+ store["write_session_state"](name, old_state)
1385
+ else:
1386
+ store["remove_session"](name)
1387
+
1388
+ def import_bundle(
1389
+ file_path,
1390
+ passphrase=None,
1391
+ session_names=None,
1392
+ force=False,
1393
+ merge=False,
1394
+ allow_authless_force=False,
1395
+ ):
1330
1396
  if not file_path or not os.path.isfile(file_path):
1331
1397
  raise CdxError(f"Bundle file not found: {file_path}")
1332
1398
  with open(file_path, "rb") as handle:
@@ -1348,73 +1414,91 @@ def create_session_service(options=None):
1348
1414
  conflicts = [name for name in names if name in existing]
1349
1415
  if conflicts and not force and not merge:
1350
1416
  raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
1417
+ if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
1418
+ raise CdxError(
1419
+ "Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
1420
+ "Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
1421
+ )
1422
+
1423
+ for session_payload in imported_sessions:
1424
+ _validate_new_session_name(session_payload["name"])
1425
+ _normalize_provider(session_payload["provider"])
1426
+ decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
1351
1427
 
1352
1428
  for session_payload in imported_sessions:
1353
1429
  name = session_payload["name"]
1354
- _validate_new_session_name(name)
1355
1430
  provider = _normalize_provider(session_payload["provider"])
1356
1431
  is_existing = name in existing
1357
1432
 
1358
- if is_existing and force:
1359
- remove_session(name)
1360
- is_existing = False
1361
-
1362
1433
  session_root = _get_session_root(name)
1363
1434
  auth_home = _get_session_auth_home(name, provider)
1364
1435
  _ensure_private_dir(base_dir)
1365
1436
  _ensure_private_dir(os.path.join(base_dir, "profiles"))
1437
+ backup_root = None
1438
+ old_record = None
1439
+ old_state = None
1440
+ if is_existing and force:
1441
+ old_record = store["get_session"](name)
1442
+ old_state = store["read_session_state"](name)
1443
+ if os.path.exists(session_root):
1444
+ backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
1445
+ os.rmdir(backup_root)
1446
+ os.rename(session_root, backup_root)
1447
+ is_existing = False
1366
1448
  _ensure_private_dir(session_root)
1367
1449
  _ensure_private_dir(auth_home)
1368
1450
 
1369
1451
  existing_state_before = None
1370
- if is_existing and merge:
1371
- existing_record = store["get_session"](name) or {}
1372
- # replace_session resets the state file to defaults, so the
1373
- # local state must be captured before it runs.
1374
- existing_state_before = store["read_session_state"](name) or {}
1375
- bundle_record = {
1376
- **session_payload,
1377
- "provider": provider,
1378
- "enabled": session_payload.get("enabled", True) is not False,
1379
- "sessionRoot": session_root,
1380
- "authHome": auth_home,
1381
- }
1382
- # Existing values take precedence; bundle fills in missing keys only.
1383
- merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
1384
- merged_record["sessionRoot"] = session_root
1385
- merged_record["authHome"] = auth_home
1386
- store["replace_session"](name, merged_record)
1387
- else:
1388
- session_record = {
1389
- **session_payload,
1390
- "provider": provider,
1391
- "enabled": session_payload.get("enabled", True) is not False,
1392
- "sessionRoot": session_root,
1393
- "authHome": auth_home,
1394
- }
1395
- store["replace_session"](name, session_record)
1396
-
1397
- state = (payload.get("states") or {}).get(name)
1398
- if is_existing and merge:
1399
- merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
1400
- if merged_state:
1401
- store["write_session_state"](name, merged_state)
1402
- elif state is not None:
1403
- store["write_session_state"](name, state)
1404
-
1405
- for item in (payload.get("profiles") or {}).get(name, []):
1406
- rel_path = _safe_relpath(item.get("path"))
1407
- try:
1408
- content = base64.b64decode(item.get("data_b64", "").encode("ascii"))
1409
- except (AttributeError, ValueError, UnicodeEncodeError) as error:
1410
- raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
1411
- dest_path = os.path.join(session_root, rel_path)
1412
- # In merge mode, skip files that already exist locally.
1413
- if is_existing and merge and os.path.exists(dest_path):
1414
- continue
1415
- _ensure_private_dir(os.path.dirname(dest_path))
1416
- # Decrypted credentials: 0o600 from creation, no umask window.
1417
- atomic_write(dest_path, content, mode=0o600)
1452
+ try:
1453
+ if is_existing and merge:
1454
+ existing_record = store["get_session"](name) or {}
1455
+ # replace_session resets the state file to defaults, so the
1456
+ # local state must be captured before it runs.
1457
+ existing_state_before = store["read_session_state"](name) or {}
1458
+ bundle_record = {
1459
+ **session_payload,
1460
+ "provider": provider,
1461
+ "enabled": session_payload.get("enabled", True) is not False,
1462
+ "sessionRoot": session_root,
1463
+ "authHome": auth_home,
1464
+ }
1465
+ # Existing values take precedence; bundle fills in missing keys only.
1466
+ merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
1467
+ merged_record["sessionRoot"] = session_root
1468
+ merged_record["authHome"] = auth_home
1469
+ store["replace_session"](name, merged_record)
1470
+ else:
1471
+ session_record = {
1472
+ **session_payload,
1473
+ "provider": provider,
1474
+ "enabled": session_payload.get("enabled", True) is not False,
1475
+ "sessionRoot": session_root,
1476
+ "authHome": auth_home,
1477
+ }
1478
+ store["replace_session"](name, session_record)
1479
+
1480
+ state = (payload.get("states") or {}).get(name)
1481
+ if is_existing and merge:
1482
+ merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
1483
+ if merged_state:
1484
+ store["write_session_state"](name, merged_state)
1485
+ elif state is not None:
1486
+ store["write_session_state"](name, state)
1487
+
1488
+ for item in decoded_profiles.get(name, []):
1489
+ dest_path = os.path.join(session_root, item["path"])
1490
+ # In merge mode, skip files that already exist locally.
1491
+ if is_existing and merge and os.path.exists(dest_path):
1492
+ continue
1493
+ _ensure_private_dir(os.path.dirname(dest_path))
1494
+ # Decrypted credentials: 0o600 from creation, no umask window.
1495
+ atomic_write(dest_path, item["content"], mode=0o600)
1496
+ except Exception:
1497
+ _restore_import_backup(name, backup_root, session_root, old_record, old_state)
1498
+ raise
1499
+ finally:
1500
+ if backup_root and os.path.exists(backup_root):
1501
+ remove_tree(backup_root, ignore_errors=True)
1418
1502
 
1419
1503
  return {
1420
1504
  "path": file_path,
@@ -294,8 +294,8 @@ def create_session_store(base_dir):
294
294
  continue
295
295
  try:
296
296
  entry = json.loads(line)
297
- except json.JSONDecodeError as error:
298
- raise CdxError(f"Corrupt JSONL file: {launch_history_file}") from error
297
+ except json.JSONDecodeError:
298
+ continue
299
299
  if session_name and entry.get("session_name") != session_name:
300
300
  continue
301
301
  entries.append(entry)
@@ -20,10 +20,6 @@ _KEY_VALUE_PATTERNS = [
20
20
  ("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
21
21
  ("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
22
22
  ("credits", re.compile(r"credits?\s*[:=]\s*(\d[\d, ]*(?:\.\d+)?)\s*(?:credits?)?", re.I)),
23
- ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
24
- ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
25
- ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
26
- ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
27
23
  ("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
28
24
  ("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
29
25
  ("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
@@ -32,6 +28,14 @@ _KEY_VALUE_PATTERNS = [
32
28
  ("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
33
29
  ]
34
30
 
31
+ _CODEX_LIMIT_PERCENT_PATTERNS = [
32
+ ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
33
+ ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
34
+ ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
35
+ ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
36
+ ]
37
+ _KEY_VALUE_PATTERNS.extend(_CODEX_LIMIT_PERCENT_PATTERNS)
38
+
35
39
 
36
40
  def _strip_ansi(text):
37
41
  return _ANSI_ESCAPE.sub("", str(text or ""))
@@ -103,6 +107,27 @@ def _is_zero_credit_balance(value):
103
107
  return False
104
108
 
105
109
 
110
+ def _get_structured_rate_limit_window(rate_limits, duration_mins, fallback_key):
111
+ for key in ("primary", "secondary"):
112
+ window = rate_limits.get(key) or {}
113
+ if window.get("windowDurationMins") == duration_mins:
114
+ return window
115
+ if window.get("window_minutes") == duration_mins:
116
+ return window
117
+ fallback = rate_limits.get(fallback_key) or {}
118
+ if "windowDurationMins" in fallback or "window_minutes" in fallback:
119
+ return {}
120
+ return fallback
121
+
122
+
123
+ def _get_rate_limit_used_percent(window):
124
+ return window.get("used_percent", window.get("usedPercent"))
125
+
126
+
127
+ def _get_rate_limit_reset_at(window):
128
+ return window.get("resets_at", window.get("resetsAt"))
129
+
130
+
106
131
  def _extract_structured_rate_limits(record):
107
132
  if not isinstance(record, dict):
108
133
  return None
@@ -111,25 +136,27 @@ def _extract_structured_rate_limits(record):
111
136
  if not isinstance(rate_limits, dict):
112
137
  return None
113
138
 
114
- primary = rate_limits.get("primary") or {}
115
- secondary = rate_limits.get("secondary") or {}
139
+ five_hour = _get_structured_rate_limit_window(rate_limits, 300, "primary")
140
+ weekly = _get_structured_rate_limit_window(rate_limits, 10080, "secondary")
116
141
  credits = rate_limits.get("credits") or {}
117
142
 
118
- primary_used = _coerce_percentage(primary.get("used_percent"))
119
- secondary_used = _coerce_percentage(secondary.get("used_percent"))
120
- remaining_5h_pct = None if primary_used is None else max(0, 100 - primary_used)
121
- remaining_week_pct = None if secondary_used is None else max(0, 100 - secondary_used)
143
+ five_hour_used = _coerce_percentage(_get_rate_limit_used_percent(five_hour))
144
+ weekly_used = _coerce_percentage(_get_rate_limit_used_percent(weekly))
145
+ remaining_5h_pct = None if five_hour_used is None else max(0, 100 - five_hour_used)
146
+ remaining_week_pct = None if weekly_used is None else max(0, 100 - weekly_used)
122
147
 
123
148
  result = {
124
- "usage_pct": primary_used if primary_used is not None else secondary_used,
149
+ "usage_pct": five_hour_used if five_hour_used is not None else weekly_used,
125
150
  "remaining_5h_pct": remaining_5h_pct,
126
151
  "remaining_week_pct": remaining_week_pct,
127
152
  "credits": None,
128
- "reset_5h_at": _normalize_timestamp_value(primary.get("resets_at")),
129
- "reset_week_at": _normalize_timestamp_value(secondary.get("resets_at")),
153
+ "reset_5h_at": _normalize_timestamp_value(_get_rate_limit_reset_at(five_hour)),
154
+ "reset_week_at": _normalize_timestamp_value(_get_rate_limit_reset_at(weekly)),
130
155
  "reset_at": None,
131
156
  "raw_status_text": json.dumps(rate_limits, sort_keys=True),
132
157
  }
158
+ if not five_hour and weekly:
159
+ result["_skip_5h_backfill"] = True
133
160
 
134
161
  balance = credits.get("balance")
135
162
  if balance not in (None, "") and not (
@@ -496,18 +523,10 @@ def extract_named_statuses_from_text(text):
496
523
  break
497
524
 
498
525
  for line in lines:
499
- m = re.search(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", line, re.I)
500
- if m and "remaining_5h_pct" not in result:
501
- result["remaining_5h_pct"] = int(m[1])
502
- m = re.search(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", line, re.I)
503
- if m and "remaining_5h_pct" not in result:
504
- result["remaining_5h_pct"] = int(m[1])
505
- m = re.search(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", line, re.I)
506
- if m and "remaining_week_pct" not in result:
507
- result["remaining_week_pct"] = int(m[1])
508
- m = re.search(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", line, re.I)
509
- if m and "remaining_week_pct" not in result:
510
- result["remaining_week_pct"] = int(m[1])
526
+ for field, pattern in _CODEX_LIMIT_PERCENT_PATTERNS:
527
+ m = pattern.search(line)
528
+ if m and field not in result:
529
+ result[field] = int(m[1])
511
530
 
512
531
  if "remaining_5h_pct" in result and "usage_pct" not in result:
513
532
  result["usage_pct"] = max(0, 100 - result["remaining_5h_pct"])
@@ -640,6 +659,8 @@ _STATUS_VALUE_FIELDS = (
640
659
 
641
660
  def _backfill_missing_fields(target, source):
642
661
  for field in _STATUS_VALUE_FIELDS:
662
+ if target.get("_skip_5h_backfill") and field in ("remaining_5h_pct", "reset_5h_at"):
663
+ continue
643
664
  if target.get(field) is None and source.get(field) is not None:
644
665
  target[field] = source[field]
645
666
 
@@ -694,6 +715,7 @@ def find_latest_status_artifact(
694
715
  "reset_week_at": candidate["structured"].get("reset_week_at"),
695
716
  "reset_at": candidate["structured"].get("reset_at"),
696
717
  "raw_status_text": candidate["structured"].get("raw_status_text"),
718
+ "_skip_5h_backfill": candidate["structured"].get("_skip_5h_backfill"),
697
719
  }
698
720
  if not parsed:
699
721
  continue
@@ -76,15 +76,15 @@ def _format_credits(value, empty="n/a"):
76
76
  def _format_status_rows(rows, use_color=False, small=False):
77
77
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
78
78
  if small:
79
- headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
79
+ headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESETS", "RESET 5H", "RESET WEEK"]
80
80
  elif has_provider:
81
- headers = ["SESSION", "PROV.", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
81
+ headers = ["SESSION", "PROV.", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
82
82
  else:
83
- headers = ["SESSION", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
83
+ headers = ["SESSION", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
84
84
  if not rows:
85
85
  if small:
86
- return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
87
- return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
86
+ return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
87
+ return "SESSION STATUS OK 5H WEEK BLOCK CR RESETS RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
88
88
  headers = [_style(header, "1", use_color) for header in headers]
89
89
  active_rows = [r for r in rows if r.get("enabled", True) is not False]
90
90
  priority = recommend_priority_rows(rows)
@@ -109,12 +109,13 @@ def _format_status_rows(rows, use_color=False, small=False):
109
109
  auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
110
110
  base.append(_style(auth, auth_color, use_color))
111
111
  if r.get("enabled", True) is False:
112
- usage_columns = [_style("-", "2", use_color)] * 5
112
+ usage_columns = [_style("-", "2", use_color)] * 6
113
113
  else:
114
114
  usage_columns = [
115
115
  _style_pct(r.get("available_pct"), use_color),
116
116
  _style_pct(r.get("remaining_5h_pct"), use_color),
117
117
  _style_pct(r.get("remaining_week_pct"), use_color),
118
+ _style(str(r["reset_credits_available"]), "33", use_color) if r.get("reset_credits_available") is not None else _style("-", "2", use_color),
118
119
  _style_reset_time(r.get("reset_5h_at"), use_color),
119
120
  _style_reset_time(r.get("reset_week_at"), use_color),
120
121
  ]
@@ -367,8 +368,15 @@ def _format_status_detail(row, use_color=False):
367
368
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
368
369
  f"{_style('Block:', '1', use_color)} {_style(_format_blocking_quota(row), '33', use_color)}",
369
370
  f"{_style('Credits:', '1', use_color)} {_style(_format_credits(row.get('credits')), '33' if row.get('credits') is not None else '2', use_color)}",
371
+ f"{_style('Bonus resets:', '1', use_color)} {_style(str(row['reset_credits_available']), '33', use_color) if row.get('reset_credits_available') is not None else _style('-', '2', use_color)}",
372
+ f"{_style('Reset expiry:', '1', use_color)} {_style_reset_time(_nearest_reset_expiry(row), use_color)}",
370
373
  f"{_style('5h reset:', '1', use_color)} {_style_reset_time(row.get('reset_5h_at'), use_color)}",
371
374
  f"{_style('Week reset:', '1', use_color)} {_style_reset_time(row.get('reset_week_at'), use_color)}",
372
375
  f"{_style('Updated:', '1', use_color)} {_dim(_format_relative_age(row.get('updated_at')), use_color)}",
373
376
  ]
374
377
  return "\n".join(lines)
378
+
379
+
380
+ def _nearest_reset_expiry(row):
381
+ values = [credit.get("expires_at") for credit in row.get("reset_credits") or [] if credit.get("expires_at")]
382
+ return min(values, key=lambda value: _parse_reset_timestamp(value) or float("inf")) if values else None