cdx-manager 0.5.5 → 0.5.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.6-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -126,7 +126,7 @@ For a specific version:
126
126
 
127
127
  ```bash
128
128
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
129
- CDX_VERSION=v0.5.5 sh install.sh
129
+ CDX_VERSION=v0.5.6 sh install.sh
130
130
  ```
131
131
 
132
132
  From source:
@@ -258,8 +258,8 @@ cdx status
258
258
  | `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
259
259
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
260
260
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
261
- | `cdx notify <name> --at-reset [--poll seconds] [--once] [--json]` | Wait for a session reset time and send a desktop notification when due |
262
- | `cdx notify --next-ready [--poll seconds] [--once] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
261
+ | `cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] [--json]` | Wait for a session reset time and send a desktop notification when due |
262
+ | `cdx notify --next-ready [--poll seconds] [--once] [--refresh] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
263
263
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
264
264
  | `cdx status --small [--refresh]` / `cdx status -s [--refresh]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
265
265
  | `cdx status <name> [--json] [--refresh]` | Show detailed usage breakdown for one session |
@@ -0,0 +1,42 @@
1
+ # Changelog (`0.5.5 -> 0.5.6`)
2
+
3
+ Release date: 2026-05-24
4
+
5
+ ## Major Highlights
6
+
7
+ - Reduced noisy `cdx status` progress output by respecting fresh cached status rows.
8
+ - Stopped probing disabled sessions during status resolution and hid stale quota metrics for disabled rows.
9
+ - Refined `cdx notify --next-ready` so it waits for the next blocked account reset instead of notifying for an account that is already usable.
10
+
11
+ ## Status Output
12
+
13
+ - Added status-row cache handling so recently resolved rows do not print redundant `Checking ...` progress lines.
14
+ - Kept Claude status cache behavior aligned with the Claude refresh TTL, avoiding unnecessary Claude checks while cached usage is still fresh.
15
+ - Treated disabled sessions as display-only in `cdx status`; disabled sessions remain visible but are not refreshed or re-read from status artifacts.
16
+ - Rendered disabled-session quota columns as `-` for `OK`, `5H`, `WEEK`, `BLOCK`, `CR`, `RESET 5H`, and `RESET WEEK`.
17
+ - Preserved the stored last status internally so re-enabled sessions can resume normal status behavior.
18
+
19
+ ## Notifications
20
+
21
+ - Added `--refresh` support to `cdx notify` so notification checks can force status refreshes when requested.
22
+ - Added text-mode progress output for notification checks while keeping `--json` output clean.
23
+ - Made `cdx notify --next-ready` ignore disabled sessions.
24
+ - Made `cdx notify --next-ready` ignore sessions that are already usable and instead target the next currently blocked session with a known reset.
25
+ - Returned `No upcoming session reset available` when there is no blocked session with a reset to wait for.
26
+
27
+ ## Documentation
28
+
29
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.6`.
30
+ - Documented `--refresh` for `cdx notify` command examples.
31
+
32
+ ## Validation and Regression Coverage
33
+
34
+ - Added regression coverage for cached Codex status rows suppressing redundant checking progress.
35
+ - Added regression coverage for Claude cache TTL behavior.
36
+ - Added regression coverage for disabled sessions skipping refresh and hiding public status metrics.
37
+ - Added regression coverage for `notify --next-ready` ignoring already-available and disabled sessions.
38
+
39
+ ## Validation and Regression Evidence
40
+
41
+ - `npm run test:py`
42
+ - `./bin/cdx status`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -25,6 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "bin",
28
+ "!bin/__pycache__",
29
+ "!bin/**/*.pyc",
28
30
  "checksums",
29
31
  "changelogs",
30
32
  "src",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.5.5"
7
+ version = "0.5.6"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -49,7 +49,7 @@ from .status_view import (
49
49
  )
50
50
  from .update_check import check_for_update
51
51
 
52
- VERSION = "0.5.5"
52
+ VERSION = "0.5.6"
53
53
 
54
54
 
55
55
  # ---------------------------------------------------------------------------
@@ -83,8 +83,8 @@ def _print_help(use_color=False):
83
83
  f" {_style('cdx doctor [--json]', '36', use_color)}",
84
84
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
85
85
  f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
86
- f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
87
- f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
86
+ f" {_style('cdx notify <name> --at-reset [--refresh] [--json]', '36', use_color)}",
87
+ f" {_style('cdx notify --next-ready [--refresh] [--json]', '36', use_color)}",
88
88
  f" {_style('cdx <name> [--json]', '36', use_color)}",
89
89
  f" {_style('cdx --help', '36', use_color)}",
90
90
  f" {_style('cdx --version', '36', use_color)}",
@@ -139,6 +139,25 @@ def _make_status_progress(ctx):
139
139
  return progress
140
140
 
141
141
 
142
+ def _make_notify_progress(ctx):
143
+ def progress(event):
144
+ kind = event.get("event")
145
+ if kind == "notify_check_started":
146
+ target = event.get("session_name") or "next ready session"
147
+ ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
148
+ elif kind == "status_started":
149
+ message = f"Loading status for {event.get('session_count', 0)} session(s)..."
150
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
151
+ elif kind == "session_started":
152
+ provider = event.get("provider") or "session"
153
+ message = f"Checking {event.get('session_name')} ({provider})..."
154
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
155
+ elif kind == "notify_waiting":
156
+ message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
157
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
158
+ return progress
159
+
160
+
142
161
  def _latest_launch_transcript_path(session):
143
162
  paths = _list_launch_transcript_paths(session)
144
163
  if not paths:
@@ -626,6 +645,7 @@ def handle_notify(rest, ctx):
626
645
  notifier=notifier,
627
646
  sleep_fn=ctx["options"].get("sleep"),
628
647
  now_fn=ctx["options"].get("now"),
648
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
629
649
  )
630
650
  if parsed["json"]:
631
651
  _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
@@ -767,7 +787,10 @@ def handle_status(rest, ctx):
767
787
  ]
768
788
 
769
789
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
770
- rows = ctx["service"]["get_status_rows"](progress_callback=status_progress)
790
+ rows = ctx["service"]["get_status_rows"](
791
+ progress_callback=status_progress,
792
+ force_refresh=parsed["refresh"],
793
+ )
771
794
  if len(args) == 0:
772
795
  if parsed["json"]:
773
796
  _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
package/src/notify.py CHANGED
@@ -4,7 +4,12 @@ import subprocess
4
4
  import time
5
5
 
6
6
  from .errors import CdxError
7
- from .status_view import _parse_reset_timestamp, _recommend_priority_sessions
7
+ from .status_view import (
8
+ PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
9
+ _parse_reset_timestamp,
10
+ _priority_reset_timestamp,
11
+ _recommend_priority_sessions,
12
+ )
8
13
 
9
14
 
10
15
  def parse_notify_args(args):
@@ -12,17 +17,18 @@ def parse_notify_args(args):
12
17
  once = "--once" in args
13
18
  at_reset = "--at-reset" in args
14
19
  next_ready = "--next-ready" in args
20
+ refresh = "--refresh" in args
15
21
  poll = 60
16
22
  cleaned = []
17
23
  i = 0
18
24
  while i < len(args):
19
25
  arg = args[i]
20
- if arg in ("--json", "--once", "--at-reset", "--next-ready"):
26
+ if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh"):
21
27
  i += 1
22
28
  continue
23
29
  if arg == "--poll":
24
30
  if i + 1 >= len(args):
25
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
31
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
26
32
  try:
27
33
  poll = max(1, int(args[i + 1]))
28
34
  except ValueError as error:
@@ -37,48 +43,68 @@ def parse_notify_args(args):
37
43
  i += 1
38
44
  continue
39
45
  if arg.startswith("-"):
40
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
46
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
41
47
  cleaned.append(arg)
42
48
  i += 1
43
49
  if at_reset == next_ready:
44
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
50
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
45
51
  if at_reset and len(cleaned) != 1:
46
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once]")
52
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh]")
47
53
  if next_ready and cleaned:
48
- raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once]")
54
+ raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
49
55
  return {
50
56
  "name": cleaned[0] if cleaned else None,
51
57
  "mode": "at-reset" if at_reset else "next-ready",
52
58
  "poll": poll,
53
59
  "once": once,
54
60
  "json": json_flag,
61
+ "refresh": refresh,
55
62
  }
56
63
 
57
64
 
58
- def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None):
65
+ def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None, progress_callback=None):
59
66
  notifier = notifier or send_desktop_notification
60
67
  sleep_fn = sleep_fn or time.sleep
61
68
  now_fn = now_fn or time.time
62
69
  while True:
63
- event = resolve_notify_event(service["get_status_rows"](), parsed, now_fn())
70
+ if progress_callback:
71
+ progress_callback({"event": "notify_check_started", "mode": parsed["mode"], "session_name": parsed["name"]})
72
+ event = resolve_notify_event(
73
+ service["get_status_rows"](
74
+ progress_callback=progress_callback,
75
+ force_refresh=parsed.get("refresh", False),
76
+ ),
77
+ parsed,
78
+ now_fn(),
79
+ )
64
80
  if event["ready"] or parsed["once"]:
65
81
  if event["ready"]:
66
82
  notifier(event["title"], event["message"])
67
83
  return event
84
+ if progress_callback:
85
+ progress_callback({
86
+ "event": "notify_waiting",
87
+ "message": event["message"],
88
+ "poll": parsed["poll"],
89
+ "session_name": event.get("session"),
90
+ "target_timestamp": event.get("target_timestamp"),
91
+ })
68
92
  sleep_fn(parsed["poll"])
69
93
 
70
94
 
71
95
  def resolve_notify_event(rows, parsed, now_ts=None):
72
96
  now_ts = time.time() if now_ts is None else now_ts
73
97
  if parsed["mode"] == "next-ready":
74
- priority = _recommend_priority_sessions(rows)
98
+ active_rows = [row for row in rows if row.get("enabled", True) is not False]
99
+ priority = _recommend_priority_sessions([
100
+ row for row in active_rows
101
+ if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
102
+ ])
75
103
  if not priority:
76
- return _event(False, "cdx", "No session status available", None)
104
+ return _event(False, "cdx", "No upcoming session reset available", None)
77
105
  first = priority[0]
78
- if _is_available(first):
79
- return _event(True, "cdx", f"{first['session_name']} is ready", first["session_name"])
80
- timestamp = _next_reset_timestamp(first)
81
- if timestamp is not None and timestamp <= now_ts:
106
+ timestamp = _priority_reset_timestamp(first)
107
+ if _needs_refresh(first, timestamp, now_ts):
82
108
  return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
83
109
  return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
84
110
 
@@ -93,9 +119,16 @@ def resolve_notify_event(rows, parsed, now_ts=None):
93
119
  return _event(False, "cdx", f"Waiting for {row['session_name']} reset", row["session_name"], timestamp)
94
120
 
95
121
 
96
- def _is_available(row):
122
+ def _is_usable_now(row):
123
+ value = row.get("available_pct")
124
+ return value is not None and value > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
125
+
126
+
127
+ def _needs_refresh(row, reset_timestamp, now_ts):
97
128
  value = row.get("available_pct")
98
- return value is not None and value > 0
129
+ if value is None or value > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
130
+ return False
131
+ return reset_timestamp is not None and reset_timestamp < now_ts
99
132
 
100
133
 
101
134
  def _next_reset_timestamp(row):
@@ -45,6 +45,8 @@ RESERVED_SESSION_NAMES = {
45
45
  "--version",
46
46
  "-v",
47
47
  }
48
+ STATUS_CACHE_TTL_SECONDS = 60
49
+ CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
48
50
 
49
51
 
50
52
  def _encode(name):
@@ -133,6 +135,24 @@ def _parse_status_timestamp(value):
133
135
  return None
134
136
 
135
137
 
138
+ def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
139
+ if session.get("provider") == PROVIDER_CLAUDE and ttl_seconds == STATUS_CACHE_TTL_SECONDS:
140
+ return CLAUDE_STATUS_CACHE_TTL_SECONDS
141
+ return ttl_seconds
142
+
143
+
144
+ def _is_status_cache_fresh(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
145
+ status = session.get("lastStatus") or {}
146
+ if _is_low_confidence_status_source(status):
147
+ return False
148
+ updated_at = _parse_status_timestamp(status.get("updated_at") or session.get("lastStatusAt"))
149
+ if not updated_at:
150
+ return False
151
+ now = datetime.now(timezone.utc).astimezone()
152
+ ttl_seconds = _status_cache_ttl_seconds(session, ttl_seconds)
153
+ return (now - updated_at.astimezone(now.tzinfo)).total_seconds() < ttl_seconds
154
+
155
+
136
156
  def _is_status_newer(candidate, current):
137
157
  if not candidate:
138
158
  return False
@@ -572,8 +592,12 @@ def create_session_service(options=None):
572
592
  raise CdxError(f"Unknown session: {name}")
573
593
  return updated
574
594
 
575
- def _resolve_session_status(session):
595
+ def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
576
596
  current_status = session.get("lastStatus")
597
+ if session.get("enabled", True) is False:
598
+ return current_status
599
+ if current_status and not force_refresh and _is_status_cache_fresh(session, ttl_seconds=cache_ttl_seconds):
600
+ return current_status
577
601
  source_root = session.get("authHome") or _get_session_auth_home(
578
602
  session["name"], session["provider"]
579
603
  )
@@ -644,7 +668,7 @@ def create_session_service(options=None):
644
668
  raise CdxError(f"Unknown session: {name}")
645
669
  return updated
646
670
 
647
- def get_status_rows(progress_callback=None):
671
+ def get_status_rows(progress_callback=None, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
648
672
  sessions = list_sessions()
649
673
  if progress_callback:
650
674
  progress_callback({
@@ -653,13 +677,25 @@ def create_session_service(options=None):
653
677
  })
654
678
  resolved = []
655
679
  for s in sessions:
656
- if progress_callback:
680
+ cache_hit = (
681
+ s.get("enabled", True) is False
682
+ or (
683
+ s.get("lastStatus")
684
+ and not force_refresh
685
+ and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
686
+ )
687
+ )
688
+ if progress_callback and not cache_hit:
657
689
  progress_callback({
658
690
  "event": "session_started",
659
691
  "session_name": s["name"],
660
692
  "provider": s["provider"],
661
693
  })
662
- status = _resolve_session_status(s)
694
+ status = _resolve_session_status(
695
+ s,
696
+ force_refresh=force_refresh,
697
+ cache_ttl_seconds=cache_ttl_seconds,
698
+ )
663
699
  if progress_callback:
664
700
  progress_callback({
665
701
  "event": "session_finished",
@@ -687,18 +723,20 @@ def create_session_service(options=None):
687
723
  rows = []
688
724
  for s in resolved:
689
725
  status = s.get("lastStatus")
726
+ enabled = s.get("enabled", True) is not False
727
+ row_status = status if enabled else None
690
728
  rows.append({
691
729
  "session_name": s["name"],
692
730
  "provider": s["provider"],
693
- "enabled": s.get("enabled", True) is not False,
694
- "status": "disabled" if s.get("enabled", True) is False else "enabled",
695
- "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
696
- "remaining_week_pct": status.get("remaining_week_pct") if status else None,
697
- "credits": status.get("credits") if status else None,
698
- "available_pct": _compute_available_pct(status),
699
- "reset_5h_at": status.get("reset_5h_at") if status else None,
700
- "reset_week_at": status.get("reset_week_at") if status else None,
701
- "reset_at": status.get("reset_at") if status else None,
731
+ "enabled": enabled,
732
+ "status": "enabled" if enabled else "disabled",
733
+ "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
734
+ "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
735
+ "credits": row_status.get("credits") if row_status else None,
736
+ "available_pct": _compute_available_pct(row_status),
737
+ "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
738
+ "reset_week_at": row_status.get("reset_week_at") if row_status else None,
739
+ "reset_at": row_status.get("reset_at") if row_status else None,
702
740
  "updated_at": _to_local_iso(s.get("lastStatusAt")),
703
741
  "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
704
742
  })
@@ -82,17 +82,20 @@ def _format_status_rows(rows, use_color=False, small=False):
82
82
  base.append(r.get("provider") or "n/a")
83
83
  status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
84
84
  base.append(_style(status, "2" if status == "disabled" else "32", use_color))
85
- usage_columns = [
86
- _style_pct(r.get("available_pct"), use_color),
87
- _style_pct(r.get("remaining_5h_pct"), use_color),
88
- _style_pct(r.get("remaining_week_pct"), use_color),
89
- _style_reset_time(r.get("reset_5h_at"), use_color),
90
- _style_reset_time(r.get("reset_week_at"), use_color),
91
- ]
85
+ if r.get("enabled", True) is False:
86
+ usage_columns = [_style("-", "2", use_color)] * 5
87
+ else:
88
+ usage_columns = [
89
+ _style_pct(r.get("available_pct"), use_color),
90
+ _style_pct(r.get("remaining_5h_pct"), use_color),
91
+ _style_pct(r.get("remaining_week_pct"), use_color),
92
+ _style_reset_time(r.get("reset_5h_at"), use_color),
93
+ _style_reset_time(r.get("reset_week_at"), use_color),
94
+ ]
92
95
  if small:
93
96
  base += usage_columns
94
97
  else:
95
- block = _format_blocking_quota(r)
98
+ block = "-" if r.get("enabled", True) is False else _format_blocking_quota(r)
96
99
  credits = str(r["credits"]) if r.get("credits") is not None else "-"
97
100
  base += usage_columns[:3] + [
98
101
  _style(block, "33" if block not in ("?", "-") else "2", use_color),