cdx-manager 0.5.4 → 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.4-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
 
@@ -35,7 +35,7 @@ One command to launch any session. Zero auth juggling.
35
35
  - **Multiple accounts, one tool.** Register as many Codex or Claude sessions as you need. Each one gets its own isolated auth environment — no cross-contamination between accounts.
36
36
  - **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
37
37
  - **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
38
- - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, and last-updated timestamps for every session in one aligned table.
38
+ - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, last-updated timestamps, priority guidance, and the last launched session in one aligned table.
39
39
  - **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
40
40
  - **Shared handoff context.** Keep a per-workspace Markdown context, or build one from a source session transcript, and install it into another assistant session before switching providers or accounts.
41
41
  - **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
@@ -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.4 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 |
@@ -349,7 +349,7 @@ cdx import backup-auth.cdx --passphrase-env CDX_BUNDLE_PASSPHRASE
349
349
 
350
350
  Notes:
351
351
 
352
- - `--include-auth` is encrypted and requires a passphrase.
352
+ - `--include-auth` is encrypted, requires a passphrase, and exports only provider credential files rather than full profile caches or logs.
353
353
  - Without `--passphrase-env`, `cdx` prompts in an interactive terminal.
354
354
  - `--sessions work,perso` exports or imports only a subset.
355
355
  - `--force` allows overwriting existing destination sessions during import or replacing an existing bundle file during export.
@@ -0,0 +1,48 @@
1
+ # Changelog (`0.5.4 -> 0.5.5`)
2
+
3
+ Release date: 2026-05-24
4
+
5
+ ## Major Highlights
6
+
7
+ - Fixed Claude usage probing for OAuth credentials by using bearer-token authentication.
8
+ - Improved `cdx status` guidance with progress output, conservative reset timing, near-empty account handling, and last-launched session context.
9
+ - Made encrypted auth exports lightweight by backing up provider credential files instead of full profile caches, logs, and runtime artifacts.
10
+
11
+ ## Usage and Status
12
+
13
+ - Added progress output while `cdx status` resolves session statuses in text mode, while keeping `--json` output clean.
14
+ - Added a `Current:` line under the priority recommendation showing the last launched session, useful before running a handoff.
15
+ - Removed the older status tip line now that status output carries actionable priority and current-session context.
16
+ - Treated sessions with `5%` or less availability as empty for priority recommendations, while preserving the true percentage in the table.
17
+ - Added a conservative one-minute reset countdown margin so users are less likely to switch back before a reset is usable.
18
+
19
+ ## Claude Usage Handling
20
+
21
+ - Fixed the Claude rate-limit probe to send OAuth access tokens as `Authorization: Bearer ...`.
22
+ - Surfaced Claude HTTP failures without rate-limit headers as structured refresh warnings, including API error text when available.
23
+ - Preserved existing fallback behavior for unavailable network probes and historical status data.
24
+
25
+ ## Export and Backup
26
+
27
+ - Limited `cdx export --include-auth` to provider credential files:
28
+ - Codex: `auth.json`
29
+ - Claude: `claude-home/.claude/.credentials.json`, `claude-home/.claude.json`, and legacy `claude-home/auth.json`
30
+ - Excluded profile caches, logs, SQLite usage databases, temporary files, and provider runtime artifacts from auth bundles.
31
+ - Added export progress output in text mode and a final summary with bundle size, auth file count, and auth data size.
32
+ - Kept `cdx export --json` clean and added bundle metrics to its JSON payload.
33
+
34
+ ## Documentation
35
+
36
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.5`.
37
+ - Clarified that encrypted auth exports include credential files rather than full profile caches or logs.
38
+
39
+ ## Validation and Regression Coverage
40
+
41
+ - Added regression coverage for Claude HTTP failures without usage headers.
42
+ - Added status rendering tests for progress output, last-launched context, near-empty priority handling, and removed tip text.
43
+ - Added export/import tests proving auth bundles exclude non-auth profile files such as `logs_2.sqlite`.
44
+
45
+ ## Validation and Regression Evidence
46
+
47
+ - `python3 -m pytest test/test_cli_py.py test/test_session_service_py.py`
48
+ - `python3 -m pytest test/test_runtime_py.py test/test_cli_py.py test/test_status_source_py.py`
@@ -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.4",
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.4"
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"
@@ -4,6 +4,8 @@ import urllib.request
4
4
  import urllib.error
5
5
  from datetime import datetime, timezone
6
6
 
7
+ from .errors import CdxError
8
+
7
9
  MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
8
10
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
9
11
  CLAUDE_STATUS_PROBE_MODEL = os.environ.get("CDX_CLAUDE_STATUS_MODEL", "claude-haiku-4-5-20251001")
@@ -24,6 +26,29 @@ def _format_reset_date(unix_seconds):
24
26
  return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
25
27
 
26
28
 
29
+ def _read_http_error_message(error):
30
+ try:
31
+ body = error.read().decode("utf-8", errors="replace")
32
+ except (AttributeError, OSError, UnicodeDecodeError):
33
+ body = ""
34
+ if not body:
35
+ return ""
36
+ try:
37
+ payload = json.loads(body)
38
+ except json.JSONDecodeError:
39
+ return body.strip()
40
+ detail = payload.get("error") if isinstance(payload, dict) else None
41
+ if isinstance(detail, dict):
42
+ message = detail.get("message") or detail.get("type")
43
+ if message:
44
+ return str(message)
45
+ if isinstance(payload, dict):
46
+ message = payload.get("message") or payload.get("detail")
47
+ if message:
48
+ return str(message)
49
+ return body.strip()
50
+
51
+
27
52
  def fetch_claude_rate_limit_headers(access_token):
28
53
  body = json.dumps({
29
54
  "model": CLAUDE_STATUS_PROBE_MODEL,
@@ -35,7 +60,7 @@ def fetch_claude_rate_limit_headers(access_token):
35
60
  "https://api.anthropic.com/v1/messages",
36
61
  data=body,
37
62
  headers={
38
- "x-api-key": access_token,
63
+ "Authorization": f"Bearer {access_token}",
39
64
  "anthropic-version": "2023-06-01",
40
65
  "content-type": "application/json",
41
66
  },
@@ -47,6 +72,13 @@ def fetch_claude_rate_limit_headers(access_token):
47
72
  headers = {k.lower(): v for k, v in resp.getheaders()}
48
73
  except urllib.error.HTTPError as e:
49
74
  headers = {k.lower(): v for k, v in e.headers.items()}
75
+ if (
76
+ "anthropic-ratelimit-unified-5h-utilization" not in headers
77
+ and "anthropic-ratelimit-unified-7d-utilization" not in headers
78
+ ):
79
+ message = _read_http_error_message(e)
80
+ suffix = f": {message}" if message else ""
81
+ raise CdxError(f"Claude usage unavailable (HTTP {e.code}{suffix})") from e
50
82
  except urllib.error.URLError:
51
83
  return None
52
84
 
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.4"
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)}",
@@ -69,6 +69,95 @@ def _write_json(ctx, payload):
69
69
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
70
70
 
71
71
 
72
+ def _format_bytes(value):
73
+ if value is None:
74
+ return "n/a"
75
+ try:
76
+ amount = float(value)
77
+ except (TypeError, ValueError):
78
+ return str(value)
79
+ units = ["B", "KB", "MB", "GB", "TB"]
80
+ unit = units[0]
81
+ for unit in units:
82
+ if amount < 1024 or unit == units[-1]:
83
+ break
84
+ amount /= 1024
85
+ if unit == "B":
86
+ return f"{int(amount)} B"
87
+ return f"{amount:.1f} {unit}"
88
+
89
+
90
+ def _format_export_report(result):
91
+ lines = [
92
+ f"Path: {result['path']}",
93
+ f"Sessions: {', '.join(result['session_names']) or '-'}",
94
+ f"Auth: {'included and encrypted' if result['include_auth'] else 'not included'}",
95
+ f"Bundle size: {_format_bytes(result.get('bundle_size_bytes'))}",
96
+ ]
97
+ if result.get("include_auth"):
98
+ lines.extend([
99
+ f"Auth files: {result.get('profile_file_count', 0)}",
100
+ f"Auth data: {_format_bytes(result.get('profile_bytes'))}",
101
+ ])
102
+ return "\n".join(lines)
103
+
104
+
105
+ def _make_export_progress(ctx):
106
+ def progress(event):
107
+ kind = event.get("event")
108
+ if kind == "export_started":
109
+ auth = " with auth" if event.get("include_auth") else ""
110
+ message = f"Exporting {event.get('session_count', 0)} session(s){auth}..."
111
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
112
+ elif kind == "session_started":
113
+ message = f"Collecting {event.get('session_name')}..."
114
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
115
+ elif kind == "profile_progress":
116
+ message = f" {event.get('session_name')}: {event.get('file_count', 0)} files, {_format_bytes(event.get('bytes'))}"
117
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
118
+ elif kind == "encoding_started":
119
+ ctx["out"](f"{_dim('Encoding and encrypting bundle...', ctx['use_color'])}\n")
120
+ elif kind == "writing_started":
121
+ message = f"Writing {_format_bytes(event.get('bundle_size_bytes'))}..."
122
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
123
+ return progress
124
+
125
+
126
+ def _make_status_progress(ctx):
127
+ def progress(event):
128
+ kind = event.get("event")
129
+ if kind == "status_started":
130
+ message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
131
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
132
+ elif kind == "session_started":
133
+ provider = event.get("provider") or "session"
134
+ message = f"Checking {event.get('session_name')} ({provider})..."
135
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
136
+ elif kind == "status_finished":
137
+ message = f"Resolved {event.get('row_count', 0)} status row(s)."
138
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
139
+ return progress
140
+
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
+
72
161
  def _latest_launch_transcript_path(session):
73
162
  paths = _list_launch_transcript_paths(session)
74
163
  if not paths:
@@ -556,6 +645,7 @@ def handle_notify(rest, ctx):
556
645
  notifier=notifier,
557
646
  sleep_fn=ctx["options"].get("sleep"),
558
647
  now_fn=ctx["options"].get("now"),
648
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
559
649
  )
560
650
  if parsed["json"]:
561
651
  _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
@@ -696,7 +786,11 @@ def handle_status(rest, ctx):
696
786
  for item in refresh_errors
697
787
  ]
698
788
 
699
- rows = ctx["service"]["get_status_rows"]()
789
+ status_progress = None if parsed["json"] else _make_status_progress(ctx)
790
+ rows = ctx["service"]["get_status_rows"](
791
+ progress_callback=status_progress,
792
+ force_refresh=parsed["refresh"],
793
+ )
700
794
  if len(args) == 0:
701
795
  if parsed["json"]:
702
796
  _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
@@ -740,6 +834,7 @@ def handle_export(rest, ctx):
740
834
  session_names=parsed["session_names"],
741
835
  passphrase=passphrase,
742
836
  force=parsed["force"],
837
+ progress_callback=None if parsed["json"] else _make_export_progress(ctx),
743
838
  )
744
839
  session_count = len(result["session_names"])
745
840
  auth_suffix = " with auth" if result["include_auth"] else ""
@@ -753,6 +848,7 @@ def handle_export(rest, ctx):
753
848
  _write_json(ctx, payload)
754
849
  return 0
755
850
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
851
+ ctx["out"](f"{_format_export_report(result)}\n")
756
852
  return 0
757
853
 
758
854
 
@@ -961,9 +1057,6 @@ def handle_launch(command, ctx, initial_prompt=None):
961
1057
  if update_notice.get("url"):
962
1058
  text = f"{text} {update_notice['url']}"
963
1059
  ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
964
- if session["provider"] == PROVIDER_CODEX:
965
- if not json_flag:
966
- ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
967
1060
  _run_interactive_provider_command(
968
1061
  session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
969
1062
  signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
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
@@ -295,22 +315,39 @@ def create_session_service(options=None):
295
315
  "auth": session.get("auth"),
296
316
  }
297
317
 
298
- def _collect_profile_files(session_root):
299
- excluded_dirs = {"log", "tmp", "cache", "__pycache__", "shell_snapshots"}
318
+ def _auth_bundle_paths(provider):
319
+ if provider == PROVIDER_CLAUDE:
320
+ return [
321
+ "claude-home/.claude/.credentials.json",
322
+ "claude-home/.claude.json",
323
+ "claude-home/auth.json",
324
+ ]
325
+ return [
326
+ "auth.json",
327
+ ]
328
+
329
+ def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
300
330
  files = []
331
+ total_bytes = 0
301
332
  if not os.path.isdir(session_root):
302
- return files
303
- for dirpath, dirnames, filenames in os.walk(session_root):
304
- dirnames[:] = [name for name in dirnames if name not in excluded_dirs]
305
- for filename in filenames:
306
- full_path = os.path.join(dirpath, filename)
307
- if not os.path.isfile(full_path):
308
- continue
309
- rel_path = os.path.relpath(full_path, session_root)
310
- with open(full_path, "rb") as handle:
311
- content = base64.b64encode(handle.read()).decode("ascii")
312
- files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
313
- return files
333
+ return {"files": files, "file_count": 0, "bytes": 0}
334
+ for rel_path in _auth_bundle_paths(provider):
335
+ full_path = os.path.join(session_root, rel_path)
336
+ if not os.path.isfile(full_path):
337
+ continue
338
+ with open(full_path, "rb") as handle:
339
+ raw_content = handle.read()
340
+ total_bytes += len(raw_content)
341
+ content = base64.b64encode(raw_content).decode("ascii")
342
+ files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
343
+ if progress_callback:
344
+ progress_callback({
345
+ "event": "profile_progress",
346
+ "session_name": session_name,
347
+ "file_count": len(files),
348
+ "bytes": total_bytes,
349
+ })
350
+ return {"files": files, "file_count": len(files), "bytes": total_bytes}
314
351
 
315
352
  def _resolve_session_subset(session_names):
316
353
  if not session_names:
@@ -555,8 +592,12 @@ def create_session_service(options=None):
555
592
  raise CdxError(f"Unknown session: {name}")
556
593
  return updated
557
594
 
558
- def _resolve_session_status(session):
595
+ def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
559
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
560
601
  source_root = session.get("authHome") or _get_session_auth_home(
561
602
  session["name"], session["provider"]
562
603
  )
@@ -627,11 +668,40 @@ def create_session_service(options=None):
627
668
  raise CdxError(f"Unknown session: {name}")
628
669
  return updated
629
670
 
630
- def get_status_rows():
671
+ def get_status_rows(progress_callback=None, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
631
672
  sessions = list_sessions()
673
+ if progress_callback:
674
+ progress_callback({
675
+ "event": "status_started",
676
+ "session_count": len(sessions),
677
+ })
632
678
  resolved = []
633
679
  for s in sessions:
634
- status = _resolve_session_status(s)
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:
689
+ progress_callback({
690
+ "event": "session_started",
691
+ "session_name": s["name"],
692
+ "provider": s["provider"],
693
+ })
694
+ status = _resolve_session_status(
695
+ s,
696
+ force_refresh=force_refresh,
697
+ cache_ttl_seconds=cache_ttl_seconds,
698
+ )
699
+ if progress_callback:
700
+ progress_callback({
701
+ "event": "session_finished",
702
+ "session_name": s["name"],
703
+ "has_status": bool(status),
704
+ })
635
705
  resolved.append({
636
706
  **s,
637
707
  "lastStatus": status,
@@ -653,19 +723,27 @@ def create_session_service(options=None):
653
723
  rows = []
654
724
  for s in resolved:
655
725
  status = s.get("lastStatus")
726
+ enabled = s.get("enabled", True) is not False
727
+ row_status = status if enabled else None
656
728
  rows.append({
657
729
  "session_name": s["name"],
658
730
  "provider": s["provider"],
659
- "enabled": s.get("enabled", True) is not False,
660
- "status": "disabled" if s.get("enabled", True) is False else "enabled",
661
- "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
662
- "remaining_week_pct": status.get("remaining_week_pct") if status else None,
663
- "credits": status.get("credits") if status else None,
664
- "available_pct": _compute_available_pct(status),
665
- "reset_5h_at": status.get("reset_5h_at") if status else None,
666
- "reset_week_at": status.get("reset_week_at") if status else None,
667
- "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,
668
740
  "updated_at": _to_local_iso(s.get("lastStatusAt")),
741
+ "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
742
+ })
743
+ if progress_callback:
744
+ progress_callback({
745
+ "event": "status_finished",
746
+ "row_count": len(rows),
669
747
  })
670
748
  return rows
671
749
 
@@ -695,7 +773,7 @@ def create_session_service(options=None):
695
773
  def get_session_root(name):
696
774
  return _get_session_root(name)
697
775
 
698
- def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False):
776
+ def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
699
777
  if not file_path:
700
778
  raise CdxError("Export path is required.")
701
779
  if os.path.exists(file_path) and not force:
@@ -710,16 +788,36 @@ def create_session_service(options=None):
710
788
  "states": {},
711
789
  "profiles": {},
712
790
  }
791
+ profile_file_count = 0
792
+ profile_bytes = 0
793
+ if progress_callback:
794
+ progress_callback({
795
+ "event": "export_started",
796
+ "include_auth": bool(include_auth),
797
+ "session_count": len(sessions),
798
+ "session_names": [session["name"] for session in sessions],
799
+ })
713
800
  for session in sessions:
801
+ if progress_callback:
802
+ progress_callback({"event": "session_started", "session_name": session["name"]})
714
803
  payload["sessions"].append(_build_export_session_record(session))
715
804
  state = store["read_session_state"](session["name"])
716
805
  if state is not None:
717
806
  payload["states"][session["name"]] = state
718
807
  if include_auth:
719
808
  session_root = session.get("sessionRoot") or _get_session_root(session["name"])
720
- payload["profiles"][session["name"]] = _collect_profile_files(session_root)
721
-
809
+ profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
810
+ payload["profiles"][session["name"]] = profile["files"]
811
+ profile_file_count += profile["file_count"]
812
+ profile_bytes += profile["bytes"]
813
+ if progress_callback:
814
+ progress_callback({"event": "session_finished", "session_name": session["name"]})
815
+
816
+ if progress_callback:
817
+ progress_callback({"event": "encoding_started"})
722
818
  bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
819
+ if progress_callback:
820
+ progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
723
821
  _ensure_private_dir(os.path.dirname(os.path.abspath(file_path)) or ".")
724
822
  with open(file_path, "wb") as handle:
725
823
  handle.write(bundle_bytes)
@@ -732,6 +830,10 @@ def create_session_service(options=None):
732
830
  "path": file_path,
733
831
  "include_auth": include_auth,
734
832
  "session_names": [session["name"] for session in sessions],
833
+ "session_count": len(sessions),
834
+ "profile_file_count": profile_file_count if include_auth else None,
835
+ "profile_bytes": profile_bytes if include_auth else None,
836
+ "bundle_size_bytes": len(bundle_bytes),
735
837
  }
736
838
 
737
839
  def import_bundle(file_path, passphrase=None, session_names=None, force=False):
@@ -9,6 +9,9 @@ from .cli_render import (
9
9
  _style_pct,
10
10
  )
11
11
 
12
+ RESET_COUNTDOWN_SAFETY_SECONDS = 60
13
+ PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
14
+
12
15
 
13
16
  def _format_reset_time(value):
14
17
  if not value:
@@ -27,6 +30,7 @@ def _format_reset_time(value):
27
30
  if hours_ago < 24:
28
31
  return f"passed {hours_ago}h ago"
29
32
  return value
33
+ delta_s = delta_s + RESET_COUNTDOWN_SAFETY_SECONDS
30
34
  if delta_s < 60:
31
35
  return "now"
32
36
  if delta_s < 24 * 60 * 60:
@@ -78,17 +82,20 @@ def _format_status_rows(rows, use_color=False, small=False):
78
82
  base.append(r.get("provider") or "n/a")
79
83
  status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
80
84
  base.append(_style(status, "2" if status == "disabled" else "32", use_color))
81
- usage_columns = [
82
- _style_pct(r.get("available_pct"), use_color),
83
- _style_pct(r.get("remaining_5h_pct"), use_color),
84
- _style_pct(r.get("remaining_week_pct"), use_color),
85
- _style_reset_time(r.get("reset_5h_at"), use_color),
86
- _style_reset_time(r.get("reset_week_at"), use_color),
87
- ]
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
+ ]
88
95
  if small:
89
96
  base += usage_columns
90
97
  else:
91
- block = _format_blocking_quota(r)
98
+ block = "-" if r.get("enabled", True) is False else _format_blocking_quota(r)
92
99
  credits = str(r["credits"]) if r.get("credits") is not None else "-"
93
100
  base += usage_columns[:3] + [
94
101
  _style(block, "33" if block not in ("?", "-") else "2", use_color),
@@ -104,14 +111,28 @@ def _format_status_rows(rows, use_color=False, small=False):
104
111
  if len(priority) > 1 else "."
105
112
  )
106
113
  ) if priority else "Priority: no usable session status yet."
114
+ current_line = _format_current_session_line(rows)
107
115
  return "\n".join([
108
116
  _pad_table([headers] + table_rows),
109
117
  "",
110
118
  _style(priority_line, "1", use_color),
111
- _style("Tip: Codex status uses the local app-server rate-limit API when available; Claude sessions auto-refresh, use --refresh to force.", "2", use_color),
119
+ _style(current_line, "2", use_color),
112
120
  ])
113
121
 
114
122
 
123
+ def _format_current_session_line(rows):
124
+ launched = []
125
+ for row in rows:
126
+ timestamp = _parse_reset_timestamp(row.get("last_launched_at"))
127
+ if timestamp is not None:
128
+ launched.append((timestamp, row))
129
+ if not launched:
130
+ return "Current: no launched session known yet."
131
+ timestamp, row = max(launched, key=lambda item: (item[0], item[1].get("session_name") or ""))
132
+ label = _format_relative_age(row.get("last_launched_at"))
133
+ return f"Current: last launched {row['session_name']} ({label})."
134
+
135
+
115
136
  def _recommend_priority_sessions(rows):
116
137
  if not rows:
117
138
  return []
@@ -120,7 +141,7 @@ def _recommend_priority_sessions(rows):
120
141
  has_credits = row.get("credits") is not None
121
142
  credit_rank = 0 if has_credits else 1
122
143
  available = row.get("available_pct")
123
- usable_now = available is not None and available > 0
144
+ usable_now = available is not None and available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
124
145
  known_available = available is not None
125
146
  reset_timestamp = _priority_reset_timestamp(row)
126
147
  reset_is_future = reset_timestamp is not None and reset_timestamp >= _now_timestamp()
@@ -165,7 +186,7 @@ def _priority_instruction(row, position):
165
186
 
166
187
  def _priority_needs_refresh(row):
167
188
  available = row.get("available_pct")
168
- if available is None or available > 0:
189
+ if available is None or available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
169
190
  return False
170
191
  _label, is_past = _priority_reset_info(row)
171
192
  return is_past
@@ -175,7 +196,7 @@ def _priority_reason(row):
175
196
  available = row.get("available_pct")
176
197
  if available is None:
177
198
  return "status unknown"
178
- if available > 0:
199
+ if available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
179
200
  return f"{_format_pct(available)} OK"
180
201
  label, is_past = _priority_reset_info(row)
181
202
  if label: