cdx-manager 0.9.14 → 0.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/python-runner.js +4 -7
- package/changelogs/CHANGELOGS_0_9_15.md +39 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +2 -0
- package/src/claude_refresh.py +9 -2
- package/src/claude_usage.py +36 -5
- package/src/cli.py +5 -4
- package/src/cli_args.py +13 -3
- package/src/cli_commands.py +26 -21
- package/src/cli_render.py +1 -1
- package/src/codex_usage.py +3 -1
- package/src/context_store.py +3 -4
- package/src/fs_utils.py +32 -0
- package/src/provider_runtime.py +45 -13
- package/src/run_registry.py +47 -14
- package/src/run_usage.py +3 -1
- package/src/session_service.py +137 -100
- package/src/session_store.py +11 -0
- package/src/status_source.py +101 -25
- package/src/status_view.py +19 -6
- package/src/update_check.py +4 -0
- package/src/update_manager.py +3 -1
package/src/session_service.py
CHANGED
|
@@ -14,7 +14,7 @@ from .claude_usage import _decode_jwt_claims
|
|
|
14
14
|
from .codex_usage import fetch_codex_rate_limits
|
|
15
15
|
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
|
|
16
16
|
from .errors import CdxError
|
|
17
|
-
from .fs_utils import remove_tree
|
|
17
|
+
from .fs_utils import atomic_write, remove_tree
|
|
18
18
|
from .session_store import create_session_store
|
|
19
19
|
from .status_source import find_latest_status_artifact
|
|
20
20
|
|
|
@@ -266,6 +266,7 @@ def _normalize_status_payload(payload=None):
|
|
|
266
266
|
"updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
|
|
267
267
|
"raw_status_text": payload.get("raw_status_text"),
|
|
268
268
|
"source_ref": payload.get("source_ref"),
|
|
269
|
+
"structured": bool(payload.get("structured")),
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
|
|
@@ -366,6 +367,12 @@ def _merge_status_payload(current, candidate):
|
|
|
366
367
|
if merged.get(field) is None and candidate.get(field) is not None:
|
|
367
368
|
merged[field] = candidate[field]
|
|
368
369
|
|
|
370
|
+
# The structured marker qualifies the source_ref; keep them in sync when
|
|
371
|
+
# the merge adopts the candidate's ref, else a structured rollout ref
|
|
372
|
+
# would read as low-confidence scraped text.
|
|
373
|
+
if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
|
|
374
|
+
merged["structured"] = bool(candidate.get("structured"))
|
|
375
|
+
|
|
369
376
|
merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
|
|
370
377
|
return merged
|
|
371
378
|
|
|
@@ -386,6 +393,10 @@ def _compute_available_pct(status):
|
|
|
386
393
|
def _is_low_confidence_status_source(status):
|
|
387
394
|
if not status:
|
|
388
395
|
return False
|
|
396
|
+
if status.get("structured"):
|
|
397
|
+
# Exact rate_limits API data: trustworthy even when it comes from a
|
|
398
|
+
# sessions/rollout path, which otherwise flags scraped-text noise.
|
|
399
|
+
return False
|
|
389
400
|
source_ref = str(status.get("source_ref") or "").replace(os.sep, "/")
|
|
390
401
|
return "/sessions/" in source_ref and "/rollout" in source_ref
|
|
391
402
|
|
|
@@ -425,9 +436,7 @@ def _ensure_claude_attribution_disabled(auth_home):
|
|
|
425
436
|
settings = {}
|
|
426
437
|
settings["includeCoAuthoredBy"] = False
|
|
427
438
|
try:
|
|
428
|
-
|
|
429
|
-
json.dump(settings, handle, indent=2, sort_keys=True)
|
|
430
|
-
handle.write("\n")
|
|
439
|
+
atomic_write(settings_path, json.dumps(settings, indent=2, sort_keys=True) + "\n", mode=0o644)
|
|
431
440
|
except OSError:
|
|
432
441
|
return False
|
|
433
442
|
return True
|
|
@@ -478,23 +487,29 @@ def create_session_service(options=None):
|
|
|
478
487
|
)
|
|
479
488
|
|
|
480
489
|
def _session_runtime(name):
|
|
481
|
-
|
|
482
|
-
|
|
490
|
+
found = {}
|
|
491
|
+
|
|
492
|
+
def updater(state):
|
|
493
|
+
if not state:
|
|
494
|
+
return None
|
|
495
|
+
runtime = state.get("runtime")
|
|
496
|
+
if _runtime_is_active(runtime):
|
|
497
|
+
found["runtime"] = runtime
|
|
498
|
+
return None
|
|
499
|
+
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
500
|
+
return {
|
|
501
|
+
**state,
|
|
502
|
+
"status": "ready",
|
|
503
|
+
"runtime": {
|
|
504
|
+
**runtime,
|
|
505
|
+
"status": "stale",
|
|
506
|
+
"endedAt": _local_now_iso(),
|
|
507
|
+
},
|
|
508
|
+
}
|
|
483
509
|
return None
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
488
|
-
store["write_session_state"](name, {
|
|
489
|
-
**state,
|
|
490
|
-
"status": "ready",
|
|
491
|
-
"runtime": {
|
|
492
|
-
**runtime,
|
|
493
|
-
"status": "stale",
|
|
494
|
-
"endedAt": _local_now_iso(),
|
|
495
|
-
},
|
|
496
|
-
})
|
|
497
|
-
return None
|
|
510
|
+
|
|
511
|
+
store["update_session_state"](name, updater)
|
|
512
|
+
return found.get("runtime")
|
|
498
513
|
|
|
499
514
|
def _validate_new_session_name(name):
|
|
500
515
|
if not name:
|
|
@@ -755,11 +770,14 @@ def create_session_service(options=None):
|
|
|
755
770
|
raise CdxError(f"Unknown session: {name}")
|
|
756
771
|
if session.get("enabled", True) is False:
|
|
757
772
|
raise CdxError(f"Session is disabled: {name}")
|
|
758
|
-
state = store["read_session_state"](name)
|
|
759
|
-
if not state:
|
|
760
|
-
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
761
773
|
now = _local_now_iso()
|
|
762
|
-
|
|
774
|
+
|
|
775
|
+
def state_updater(state):
|
|
776
|
+
if not state:
|
|
777
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
778
|
+
return {**state, "rehydratedAt": now}
|
|
779
|
+
|
|
780
|
+
store["update_session_state"](name, state_updater)
|
|
763
781
|
return store["update_session"](name, lambda s: {
|
|
764
782
|
**s, "updatedAt": now, "lastLaunchedAt": now
|
|
765
783
|
})
|
|
@@ -768,9 +786,6 @@ def create_session_service(options=None):
|
|
|
768
786
|
session = store["get_session"](name)
|
|
769
787
|
if not session:
|
|
770
788
|
raise CdxError(f"Unknown session: {name}")
|
|
771
|
-
state = store["read_session_state"](name)
|
|
772
|
-
if not state:
|
|
773
|
-
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
774
789
|
payload = dict(payload or {})
|
|
775
790
|
now = _local_now_iso()
|
|
776
791
|
runtime = {
|
|
@@ -782,52 +797,70 @@ def create_session_service(options=None):
|
|
|
782
797
|
"label": payload.get("label"),
|
|
783
798
|
"transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
784
799
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
800
|
+
|
|
801
|
+
def updater(state):
|
|
802
|
+
if not state:
|
|
803
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
804
|
+
return {
|
|
805
|
+
**state,
|
|
806
|
+
"status": "running",
|
|
807
|
+
"runtime": runtime,
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
store["update_session_state"](name, updater)
|
|
790
811
|
return runtime
|
|
791
812
|
|
|
792
813
|
def finish_session_runtime(name, run_id=None, payload=None):
|
|
793
814
|
session = store["get_session"](name)
|
|
794
815
|
if not session:
|
|
795
816
|
raise CdxError(f"Unknown session: {name}")
|
|
796
|
-
state = store["read_session_state"](name)
|
|
797
|
-
if not state:
|
|
798
|
-
return None
|
|
799
|
-
runtime = state.get("runtime") or {}
|
|
800
|
-
if run_id and runtime.get("runId") != run_id:
|
|
801
|
-
return runtime
|
|
802
817
|
payload = dict(payload or {})
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
818
|
+
outcome = {}
|
|
819
|
+
|
|
820
|
+
def updater(state):
|
|
821
|
+
if not state:
|
|
822
|
+
return None
|
|
823
|
+
runtime = state.get("runtime") or {}
|
|
824
|
+
if run_id and runtime.get("runId") != run_id:
|
|
825
|
+
outcome["runtime"] = runtime
|
|
826
|
+
return None
|
|
827
|
+
updated_runtime = {
|
|
828
|
+
**runtime,
|
|
829
|
+
"status": payload.get("status") or "stopped",
|
|
830
|
+
"endedAt": payload.get("endedAt") or _local_now_iso(),
|
|
831
|
+
}
|
|
832
|
+
if "returncode" in payload:
|
|
833
|
+
updated_runtime["returncode"] = payload["returncode"]
|
|
834
|
+
outcome["runtime"] = updated_runtime
|
|
835
|
+
return {
|
|
836
|
+
**state,
|
|
837
|
+
"status": "ready",
|
|
838
|
+
"runtime": updated_runtime,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
store["update_session_state"](name, updater)
|
|
842
|
+
return outcome.get("runtime")
|
|
816
843
|
|
|
817
844
|
def ensure_session_state(name):
|
|
818
845
|
session = store["get_session"](name)
|
|
819
846
|
if not session:
|
|
820
847
|
raise CdxError(f"Unknown session: {name}")
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
848
|
+
result = {}
|
|
849
|
+
|
|
850
|
+
def updater(state):
|
|
851
|
+
if state:
|
|
852
|
+
result["state"] = state
|
|
853
|
+
return None
|
|
854
|
+
repaired = {
|
|
855
|
+
"provider": session["provider"],
|
|
856
|
+
"status": "ready",
|
|
857
|
+
"rehydratedAt": None,
|
|
858
|
+
}
|
|
859
|
+
result["state"] = repaired
|
|
860
|
+
return repaired
|
|
861
|
+
|
|
862
|
+
store["update_session_state"](name, updater)
|
|
863
|
+
return result["state"]
|
|
831
864
|
|
|
832
865
|
def list_sessions():
|
|
833
866
|
return store["list_sessions"]()
|
|
@@ -853,22 +886,28 @@ def create_session_service(options=None):
|
|
|
853
886
|
updates = _normalize_launch_settings(settings)
|
|
854
887
|
if not updates:
|
|
855
888
|
raise CdxError("At least one launch setting is required.")
|
|
856
|
-
current = _normalize_launch_settings(session.get("launch") or {}, mark_fast_service_tier=False)
|
|
857
|
-
launch = {**current, **updates}
|
|
858
|
-
explicit_power = "power" in updates or "reasoning_effort" in updates
|
|
859
|
-
if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
|
|
860
|
-
launch["fast"] = False
|
|
861
|
-
launch.pop("fastMode", None)
|
|
862
|
-
if updates.get("fast") is False:
|
|
863
|
-
launch.pop("fastMode", None)
|
|
864
|
-
if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
|
|
865
|
-
launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
|
|
866
889
|
now = _local_now_iso()
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
890
|
+
|
|
891
|
+
def updater(s):
|
|
892
|
+
# Build the new launch dict from the fresh record inside the store
|
|
893
|
+
# lock; a pre-lock snapshot loses a concurrent setting change.
|
|
894
|
+
current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
|
|
895
|
+
launch = {**current, **updates}
|
|
896
|
+
explicit_power = "power" in updates or "reasoning_effort" in updates
|
|
897
|
+
if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
|
|
898
|
+
launch["fast"] = False
|
|
899
|
+
launch.pop("fastMode", None)
|
|
900
|
+
if updates.get("fast") is False:
|
|
901
|
+
launch.pop("fastMode", None)
|
|
902
|
+
if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
|
|
903
|
+
launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
|
|
904
|
+
return {
|
|
905
|
+
**s,
|
|
906
|
+
"launch": launch,
|
|
907
|
+
"updatedAt": now,
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return store["update_session"](name, updater)
|
|
872
911
|
|
|
873
912
|
def unset_launch_settings(name, keys):
|
|
874
913
|
session = store["get_session"](name)
|
|
@@ -880,12 +919,12 @@ def create_session_service(options=None):
|
|
|
880
919
|
unknown = [key for key in keys if key not in allowed]
|
|
881
920
|
if unknown:
|
|
882
921
|
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
883
|
-
current = dict(session.get("launch") or {})
|
|
884
|
-
for key in keys:
|
|
885
|
-
current.pop(key, None)
|
|
886
922
|
now = _local_now_iso()
|
|
887
923
|
|
|
888
924
|
def updater(s):
|
|
925
|
+
current = dict(s.get("launch") or {})
|
|
926
|
+
for key in keys:
|
|
927
|
+
current.pop(key, None)
|
|
889
928
|
updated = {**s, "updatedAt": now}
|
|
890
929
|
if current:
|
|
891
930
|
updated["launch"] = current
|
|
@@ -972,6 +1011,9 @@ def create_session_service(options=None):
|
|
|
972
1011
|
global_root,
|
|
973
1012
|
session["provider"],
|
|
974
1013
|
expected_account_email=expected_account_email,
|
|
1014
|
+
# The shared codex home can hold other accounts' rollouts;
|
|
1015
|
+
# structured payloads there cannot be attributed to ours.
|
|
1016
|
+
trust_unattributed_structured=False,
|
|
975
1017
|
)
|
|
976
1018
|
if not artifact:
|
|
977
1019
|
if _is_low_confidence_status_source(current_status):
|
|
@@ -988,6 +1030,7 @@ def create_session_service(options=None):
|
|
|
988
1030
|
"updated_at": artifact.get("updated_at"),
|
|
989
1031
|
"raw_status_text": artifact.get("raw_status_text"),
|
|
990
1032
|
"source_ref": artifact.get("source_ref"),
|
|
1033
|
+
"structured": artifact.get("structured"),
|
|
991
1034
|
})
|
|
992
1035
|
if _is_low_confidence_status_source(current_status) and not _is_low_confidence_status_source(resolved):
|
|
993
1036
|
record_status(session["name"], resolved)
|
|
@@ -1269,14 +1312,10 @@ def create_session_service(options=None):
|
|
|
1269
1312
|
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
1270
1313
|
if progress_callback:
|
|
1271
1314
|
progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
try:
|
|
1277
|
-
os.chmod(file_path, 0o600)
|
|
1278
|
-
except OSError:
|
|
1279
|
-
pass
|
|
1315
|
+
# Atomic + 0o600 from the start: the export path may live in a
|
|
1316
|
+
# world-traversable directory, and a crash must not clobber a
|
|
1317
|
+
# previous good bundle with a truncated one.
|
|
1318
|
+
atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
|
|
1280
1319
|
return {
|
|
1281
1320
|
"path": file_path,
|
|
1282
1321
|
"include_auth": include_auth,
|
|
@@ -1327,8 +1366,12 @@ def create_session_service(options=None):
|
|
|
1327
1366
|
_ensure_private_dir(session_root)
|
|
1328
1367
|
_ensure_private_dir(auth_home)
|
|
1329
1368
|
|
|
1369
|
+
existing_state_before = None
|
|
1330
1370
|
if is_existing and merge:
|
|
1331
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 {}
|
|
1332
1375
|
bundle_record = {
|
|
1333
1376
|
**session_payload,
|
|
1334
1377
|
"provider": provider,
|
|
@@ -1352,13 +1395,12 @@ def create_session_service(options=None):
|
|
|
1352
1395
|
store["replace_session"](name, session_record)
|
|
1353
1396
|
|
|
1354
1397
|
state = (payload.get("states") or {}).get(name)
|
|
1355
|
-
if
|
|
1356
|
-
if
|
|
1357
|
-
|
|
1358
|
-
merged_state = {**state, **{k: v for k, v in existing_state.items() if v is not None}}
|
|
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:
|
|
1359
1401
|
store["write_session_state"](name, merged_state)
|
|
1360
|
-
|
|
1361
|
-
|
|
1402
|
+
elif state is not None:
|
|
1403
|
+
store["write_session_state"](name, state)
|
|
1362
1404
|
|
|
1363
1405
|
for item in (payload.get("profiles") or {}).get(name, []):
|
|
1364
1406
|
rel_path = _safe_relpath(item.get("path"))
|
|
@@ -1371,13 +1413,8 @@ def create_session_service(options=None):
|
|
|
1371
1413
|
if is_existing and merge and os.path.exists(dest_path):
|
|
1372
1414
|
continue
|
|
1373
1415
|
_ensure_private_dir(os.path.dirname(dest_path))
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
if sys.platform != "win32":
|
|
1377
|
-
try:
|
|
1378
|
-
os.chmod(dest_path, 0o600)
|
|
1379
|
-
except OSError:
|
|
1380
|
-
pass
|
|
1416
|
+
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
1417
|
+
atomic_write(dest_path, content, mode=0o600)
|
|
1381
1418
|
|
|
1382
1419
|
return {
|
|
1383
1420
|
"path": file_path,
|
package/src/session_store.py
CHANGED
|
@@ -260,6 +260,16 @@ def create_session_store(base_dir):
|
|
|
260
260
|
with _file_lock(lock_file):
|
|
261
261
|
_write_session_state_unlocked(name, state)
|
|
262
262
|
|
|
263
|
+
def update_session_state(name, updater):
|
|
264
|
+
# Read-modify-write under one lock; separate read/write calls let a
|
|
265
|
+
# concurrent writer's update be clobbered between the two.
|
|
266
|
+
with _file_lock(lock_file):
|
|
267
|
+
state = _read_session_state_unlocked(name)
|
|
268
|
+
updated = updater(state)
|
|
269
|
+
if updated is not None:
|
|
270
|
+
_write_session_state_unlocked(name, updated)
|
|
271
|
+
return updated
|
|
272
|
+
|
|
263
273
|
def append_launch_history(entry):
|
|
264
274
|
with _file_lock(lock_file):
|
|
265
275
|
_ensure_dir(state_dir)
|
|
@@ -303,6 +313,7 @@ def create_session_store(base_dir):
|
|
|
303
313
|
"replace_session": replace_session,
|
|
304
314
|
"read_session_state": read_session_state,
|
|
305
315
|
"write_session_state": write_session_state,
|
|
316
|
+
"update_session_state": update_session_state,
|
|
306
317
|
"append_launch_history": append_launch_history,
|
|
307
318
|
"list_launch_history": list_launch_history,
|
|
308
319
|
}
|
package/src/status_source.py
CHANGED
|
@@ -19,7 +19,7 @@ _KEY_VALUE_PATTERNS = [
|
|
|
19
19
|
("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
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
|
-
("credits", re.compile(r"credits?\s*[:=]\s*(
|
|
22
|
+
("credits", re.compile(r"credits?\s*[:=]\s*(\d[\d, ]*(?:\.\d+)?)\s*(?:credits?)?", re.I)),
|
|
23
23
|
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
24
24
|
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
25
25
|
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
@@ -94,6 +94,15 @@ def _coerce_percentage(value):
|
|
|
94
94
|
return None
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def _is_zero_credit_balance(value):
|
|
98
|
+
if value in (None, ""):
|
|
99
|
+
return True
|
|
100
|
+
try:
|
|
101
|
+
return float(str(value).replace(",", "").strip()) == 0
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
97
106
|
def _extract_structured_rate_limits(record):
|
|
98
107
|
if not isinstance(record, dict):
|
|
99
108
|
return None
|
|
@@ -123,7 +132,11 @@ def _extract_structured_rate_limits(record):
|
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
balance = credits.get("balance")
|
|
126
|
-
if balance not in (None, "")
|
|
135
|
+
if balance not in (None, "") and not (
|
|
136
|
+
_is_zero_credit_balance(balance)
|
|
137
|
+
and not credits.get("hasCredits")
|
|
138
|
+
and not credits.get("unlimited")
|
|
139
|
+
):
|
|
127
140
|
result["credits"] = str(balance).strip()
|
|
128
141
|
|
|
129
142
|
result["reset_at"] = result["reset_week_at"] or result["reset_5h_at"]
|
|
@@ -277,6 +290,8 @@ def _extract_jsonl_texts(file_path, provider=None):
|
|
|
277
290
|
"timestamp": record.get("timestamp"),
|
|
278
291
|
"text": structured["raw_status_text"],
|
|
279
292
|
"structured": structured,
|
|
293
|
+
# Exact API data, as trustworthy as a /status screen.
|
|
294
|
+
"trusted": True,
|
|
280
295
|
})
|
|
281
296
|
payload_texts = _collect_text_values(record.get("payload") or {})
|
|
282
297
|
for candidate in payload_texts:
|
|
@@ -296,7 +311,11 @@ def _extract_log_block(file_path, provider=None):
|
|
|
296
311
|
text = _safe_read_text(file_path)
|
|
297
312
|
if not text:
|
|
298
313
|
return []
|
|
299
|
-
|
|
314
|
+
items = _extract_status_blocks_from_text(text, provider=provider, source_ref=file_path, timestamp=None)
|
|
315
|
+
for item in items:
|
|
316
|
+
# A /status screen captured in a terminal log is authoritative.
|
|
317
|
+
item["trusted"] = True
|
|
318
|
+
return items
|
|
300
319
|
|
|
301
320
|
|
|
302
321
|
def _parse_month_index(name):
|
|
@@ -422,7 +441,14 @@ def extract_named_statuses_from_text(text):
|
|
|
422
441
|
for field, pattern in _KEY_VALUE_PATTERNS:
|
|
423
442
|
if field not in result:
|
|
424
443
|
m = pattern.search(normalized)
|
|
425
|
-
if m:
|
|
444
|
+
if not m:
|
|
445
|
+
continue
|
|
446
|
+
if field == "credits":
|
|
447
|
+
# Keep the decimal part; a zero balance is no credit at all.
|
|
448
|
+
value = m[1].replace(",", "").replace(" ", "")
|
|
449
|
+
if not _is_zero_credit_balance(value):
|
|
450
|
+
result[field] = value
|
|
451
|
+
else:
|
|
426
452
|
result[field] = int(re.sub(r"\D", "", m[1]))
|
|
427
453
|
|
|
428
454
|
# Claude "Current session / Current week" block
|
|
@@ -596,12 +622,49 @@ def _sort_recent(paths):
|
|
|
596
622
|
}
|
|
597
623
|
return sorted(
|
|
598
624
|
candidate_stats,
|
|
599
|
-
key=lambda fp: candidate_stats[fp].st_mtime,
|
|
625
|
+
key=lambda fp: (candidate_stats[fp].st_mtime, fp),
|
|
600
626
|
reverse=True,
|
|
601
627
|
)
|
|
602
628
|
|
|
603
629
|
|
|
604
|
-
|
|
630
|
+
_STATUS_VALUE_FIELDS = (
|
|
631
|
+
"usage_pct",
|
|
632
|
+
"remaining_5h_pct",
|
|
633
|
+
"remaining_week_pct",
|
|
634
|
+
"credits",
|
|
635
|
+
"reset_5h_at",
|
|
636
|
+
"reset_week_at",
|
|
637
|
+
"reset_at",
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _backfill_missing_fields(target, source):
|
|
642
|
+
for field in _STATUS_VALUE_FIELDS:
|
|
643
|
+
if target.get(field) is None and source.get(field) is not None:
|
|
644
|
+
target[field] = source[field]
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _record_timestamp_epoch(value):
|
|
648
|
+
"""Best-effort epoch seconds from a record timestamp (ISO string, epoch s or ms)."""
|
|
649
|
+
if value in (None, ""):
|
|
650
|
+
return None
|
|
651
|
+
try:
|
|
652
|
+
number = float(value)
|
|
653
|
+
except (TypeError, ValueError):
|
|
654
|
+
try:
|
|
655
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
|
656
|
+
except (TypeError, ValueError):
|
|
657
|
+
return None
|
|
658
|
+
# ponytail: >1e11 means epoch milliseconds (that's year 5138 in seconds)
|
|
659
|
+
return number / 1000 if number > 1e11 else number
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def find_latest_status_artifact(
|
|
663
|
+
root_dir,
|
|
664
|
+
provider=None,
|
|
665
|
+
expected_account_email=None,
|
|
666
|
+
trust_unattributed_structured=True,
|
|
667
|
+
):
|
|
605
668
|
priority_candidates, history_candidates = _collect_candidate_files(root_dir)
|
|
606
669
|
candidates = (
|
|
607
670
|
_sort_recent(priority_candidates)
|
|
@@ -634,35 +697,48 @@ def find_latest_status_artifact(root_dir, provider=None, expected_account_email=
|
|
|
634
697
|
}
|
|
635
698
|
if not parsed:
|
|
636
699
|
continue
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
score = 0
|
|
700
|
+
# Score on the record's own time so sources stay comparable: falling
|
|
701
|
+
# back to file mtime would let a stale block in an actively-appended
|
|
702
|
+
# jsonl outrank a genuinely fresher terminal log.
|
|
703
|
+
epoch = _record_timestamp_epoch(candidate.get("timestamp"))
|
|
642
704
|
src_file = re.sub(r":\d+$", "", candidate["source_ref"])
|
|
643
705
|
stat = _safe_stat(src_file)
|
|
644
|
-
if
|
|
706
|
+
if epoch is not None:
|
|
707
|
+
score = epoch
|
|
708
|
+
elif stat:
|
|
645
709
|
score = stat.st_mtime
|
|
646
|
-
|
|
710
|
+
else:
|
|
711
|
+
score = 0
|
|
712
|
+
# Each record producer stamps "trusted" where it knows the source kind.
|
|
713
|
+
# Untrusted records are text scraped from jsonl payloads: they can be
|
|
714
|
+
# conversational noise quoting an old status block.
|
|
715
|
+
trusted = bool(candidate.get("trusted"))
|
|
716
|
+
# Structured payloads carry no account identity, so the email guard
|
|
717
|
+
# above cannot filter them. In roots shared by several accounts the
|
|
718
|
+
# caller demotes them below account-verified /status screens.
|
|
719
|
+
if trusted and candidate.get("structured") and not trust_unattributed_structured:
|
|
720
|
+
trusted = False
|
|
721
|
+
priority = 2 if trusted else 1
|
|
647
722
|
|
|
648
723
|
if best is None or (priority, score) >= (best["priority"], best["score"]):
|
|
724
|
+
previous = best
|
|
649
725
|
best = {
|
|
650
726
|
"priority": priority,
|
|
651
727
|
"score": score,
|
|
652
728
|
"source_ref": candidate["source_ref"],
|
|
729
|
+
"structured": bool(candidate.get("structured")),
|
|
653
730
|
**parsed,
|
|
654
731
|
}
|
|
655
|
-
if
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if "
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
).astimezone().isoformat()
|
|
732
|
+
if score:
|
|
733
|
+
best["updated_at"] = datetime.fromtimestamp(
|
|
734
|
+
score, tz=timezone.utc
|
|
735
|
+
).astimezone().isoformat()
|
|
736
|
+
# A fresher-but-partial winner (e.g. rate_limits with only the
|
|
737
|
+
# primary window) must not erase fields an equally trusted
|
|
738
|
+
# candidate provided.
|
|
739
|
+
if previous and previous["priority"] == priority:
|
|
740
|
+
_backfill_missing_fields(best, previous)
|
|
741
|
+
elif priority == best["priority"]:
|
|
742
|
+
_backfill_missing_fields(best, parsed)
|
|
667
743
|
|
|
668
744
|
return best
|
package/src/status_view.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import datetime
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
2
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
|
3
3
|
|
|
4
4
|
from .cli_render import (
|
|
@@ -328,12 +328,25 @@ def _parse_reset_timestamp(value):
|
|
|
328
328
|
return parsed.timestamp()
|
|
329
329
|
except (TypeError, ValueError):
|
|
330
330
|
pass
|
|
331
|
-
try:
|
|
332
|
-
parsed = datetime.strptime(text, "%b %d %H:%M")
|
|
333
|
-
except (TypeError, ValueError):
|
|
334
|
-
return None
|
|
335
331
|
now = datetime.now().astimezone()
|
|
336
|
-
parsed =
|
|
332
|
+
parsed = None
|
|
333
|
+
# Parse with an explicit year: strptime's implicit 1900 rejects "Feb 29",
|
|
334
|
+
# and the current year may not hold the date at all around new year.
|
|
335
|
+
for year in (now.year, now.year + 1):
|
|
336
|
+
try:
|
|
337
|
+
parsed = datetime.strptime(f"{year} {text}", "%Y %b %d %H:%M").replace(tzinfo=now.tzinfo)
|
|
338
|
+
break
|
|
339
|
+
except (TypeError, ValueError):
|
|
340
|
+
continue
|
|
341
|
+
if parsed is None:
|
|
342
|
+
return None
|
|
343
|
+
# A year-less date nearly a year in the past is a year wrap ("Jan 2"
|
|
344
|
+
# rendered on Dec 31), not a reset that passed months ago.
|
|
345
|
+
if parsed < now - timedelta(days=182):
|
|
346
|
+
try:
|
|
347
|
+
parsed = parsed.replace(year=parsed.year + 1)
|
|
348
|
+
except ValueError:
|
|
349
|
+
pass
|
|
337
350
|
return parsed.timestamp()
|
|
338
351
|
|
|
339
352
|
|
package/src/update_check.py
CHANGED
|
@@ -236,6 +236,10 @@ def check_logics_manager_for_update(base_dir, env=None, now_fn=None, runner=None
|
|
|
236
236
|
return None
|
|
237
237
|
|
|
238
238
|
latest_version = fetch_latest_logics_manager_version(env=env)
|
|
239
|
+
if not latest_version:
|
|
240
|
+
# Don't cache a failed fetch: a None latest_version would hide a
|
|
241
|
+
# real update for the whole TTL after the registry recovers.
|
|
242
|
+
return None
|
|
239
243
|
payload = {
|
|
240
244
|
"checked_at": now_ts,
|
|
241
245
|
"latest_version": latest_version,
|
package/src/update_manager.py
CHANGED
|
@@ -204,7 +204,9 @@ def run_update_plan(plan, runner=None, env=None):
|
|
|
204
204
|
"stdout": _result_text(result, "stdout"),
|
|
205
205
|
"stderr": _result_text(result, "stderr"),
|
|
206
206
|
})
|
|
207
|
-
|
|
207
|
+
# A runner that reports no return code is a failure, not a success:
|
|
208
|
+
# treating None as ok would let a failed step continue the plan.
|
|
209
|
+
if code != 0:
|
|
208
210
|
break
|
|
209
211
|
return results
|
|
210
212
|
|