cdx-manager 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0-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.1-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
 
@@ -38,7 +38,7 @@ One command to launch any session. Zero auth juggling.
38
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.
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 and install it into another assistant session before switching providers or accounts.
41
- - **Passive status resolution.** If a session has no recorded status, `cdx` reads it directly from the provider's session logs and JSONL history no manual sync required.
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.
42
42
  - **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
43
43
  - **Clean removal.** `cdx rmv` wipes a session and its entire auth directory. No orphaned files, no stale credentials.
44
44
 
@@ -59,6 +59,7 @@ One command to launch any session. Zero auth juggling.
59
59
  - All paths are URL-encoded to support arbitrary session names.
60
60
  - Status resolution pipeline:
61
61
  - Primary source: recorded status fields on the session record.
62
+ - Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
62
63
  - Fallback: `status-source` scans provider JSONL history files and terminal log transcripts, strips ANSI/OSC sequences, and extracts `usage%`, `5h remaining%`, and `week remaining%` via pattern matching.
63
64
  - Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
64
65
  - If `script` is unavailable, Codex launch falls back to running without transcript capture.
@@ -125,7 +126,7 @@ For a specific version:
125
126
 
126
127
  ```bash
127
128
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
128
- CDX_VERSION=v0.5.0 sh install.sh
129
+ CDX_VERSION=v0.5.1 sh install.sh
129
130
  ```
130
131
 
131
132
  From source:
@@ -446,7 +447,7 @@ Session names are URL-encoded when used as directory or file names. CLI command
446
447
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
447
448
  - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
448
449
  - **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
449
- - **Status shows `n/a` for all fields** — the session has not been launched yet, or the provider has not written any status output to its history files. Launch the session and run `/status` inside it at least once.
450
+ - **Status shows `n/a` for all fields** — the Codex app-server rate-limit probe may be unavailable, the session may not be authenticated, and no legacy transcript/history status has been captured yet.
450
451
  - **`cdx rmv` says "Removal requires confirmation in an interactive terminal"** — pass `--force` to bypass the prompt in non-interactive environments (scripts, CI).
451
452
  - **`cdx login` hangs** — the provider's login flow requires a browser or device code. Follow the on-screen instructions in the terminal that opened.
452
453
  - **`make install` says `npm link` is not found** — ensure Node.js and npm are installed and in your PATH.
@@ -0,0 +1,33 @@
1
+ # Changelog (`0.5.0 -> 0.5.1`)
2
+
3
+ Release date: 2026-05-22
4
+
5
+ ## Major Highlights
6
+
7
+ - Replaced Codex status scraping with a structured local Codex app-server rate-limit probe.
8
+ - Kept transcript and JSONL status parsing as a legacy fallback when the local Codex app-server is unavailable.
9
+ - Prepared the package metadata for the 0.5.1 release across npm, PyPI, CLI version output, and README install examples.
10
+
11
+ ## Codex Status Resolution
12
+
13
+ - Added a Codex app-server JSON-RPC client that calls `account/rateLimits/read` for each isolated Codex session profile.
14
+ - Normalized app-server rate-limit snapshots into the existing `cdx status` output fields: 5-hour remaining percentage, weekly remaining percentage, reset times, credits, and source metadata.
15
+ - Preserved multi-account isolation by running each probe with the session-specific `CODEX_HOME`.
16
+ - Updated status and launch tips so `/status` transcript capture is no longer presented as the normal Codex refresh path.
17
+
18
+ ## Packaging
19
+
20
+ - Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.1`.
21
+ - Updated the README version badge and pinned installer example for `v0.5.1`.
22
+
23
+ ## Validation and Regression Coverage
24
+
25
+ - Added runtime coverage for Codex app-server JSON-RPC probing and rate-limit normalization.
26
+ - Added session-service coverage proving app-server status is preferred over legacy transcript artifacts.
27
+ - Updated CLI coverage for the revised Codex status guidance.
28
+
29
+ ## Validation and Regression Evidence
30
+
31
+ - `python3 -m unittest discover -s test -p 'test_*_py.py'`
32
+ - `python logics/skills/logics.py lint --require-status`
33
+ - `python3 bin/cdx status --json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
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.0"
7
+ version = "0.5.1"
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.0"
52
+ VERSION = "0.5.1"
53
53
 
54
54
 
55
55
  # ---------------------------------------------------------------------------
@@ -946,7 +946,7 @@ def handle_launch(command, ctx):
946
946
  ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
947
947
  if session["provider"] == "codex":
948
948
  if not json_flag:
949
- ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
949
+ ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
950
950
  _run_interactive_provider_command(
951
951
  session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
952
952
  signal_emitter=ctx.get("signal_emitter")
@@ -0,0 +1,168 @@
1
+ import json
2
+ import os
3
+ import queue
4
+ import subprocess
5
+ import threading
6
+ from datetime import datetime, timezone
7
+
8
+ MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
9
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
10
+
11
+
12
+ def _format_reset_date(unix_seconds):
13
+ if unix_seconds is None:
14
+ return None
15
+ try:
16
+ dt = datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc).astimezone()
17
+ except (TypeError, ValueError, OSError):
18
+ return None
19
+ return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
20
+
21
+
22
+ def _remaining_from_used_percent(value):
23
+ if value is None:
24
+ return None
25
+ try:
26
+ return max(0, min(100, round(100 - float(value))))
27
+ except (TypeError, ValueError):
28
+ return None
29
+
30
+
31
+ def _get_window(snapshot, duration_mins):
32
+ for key in ("primary", "secondary"):
33
+ window = snapshot.get(key) or {}
34
+ if window.get("windowDurationMins") == duration_mins:
35
+ return window
36
+ if window.get("window_minutes") == duration_mins:
37
+ return window
38
+ return {}
39
+
40
+
41
+ def normalize_codex_rate_limit_snapshot(snapshot):
42
+ if not snapshot:
43
+ return None
44
+
45
+ five_hour = _get_window(snapshot, 300)
46
+ weekly = _get_window(snapshot, 10080)
47
+ credits = snapshot.get("credits")
48
+ credit_balance = None
49
+ if isinstance(credits, dict):
50
+ credit_balance = credits.get("balance")
51
+ if not credits.get("hasCredits") and not credits.get("unlimited") and str(credit_balance or "0") == "0":
52
+ credit_balance = None
53
+ elif credits is not None:
54
+ credit_balance = credits
55
+
56
+ reset_5h_at = _format_reset_date(five_hour.get("resetsAt") or five_hour.get("resets_at"))
57
+ reset_week_at = _format_reset_date(weekly.get("resetsAt") or weekly.get("resets_at"))
58
+
59
+ raw_status_text = json.dumps(snapshot, sort_keys=True)
60
+ return {
61
+ "remaining_5h_pct": _remaining_from_used_percent(
62
+ five_hour.get("usedPercent", five_hour.get("used_percent"))
63
+ ),
64
+ "remaining_week_pct": _remaining_from_used_percent(
65
+ weekly.get("usedPercent", weekly.get("used_percent"))
66
+ ),
67
+ "credits": credit_balance,
68
+ "reset_5h_at": reset_5h_at,
69
+ "reset_week_at": reset_week_at,
70
+ "reset_at": reset_week_at or reset_5h_at,
71
+ "updated_at": datetime.now().astimezone().isoformat(),
72
+ "raw_status_text": raw_status_text,
73
+ "source_ref": "api:codex-app-server-rate-limits",
74
+ }
75
+
76
+
77
+ def _reader_thread(stream, output):
78
+ try:
79
+ for line in stream:
80
+ output.put(line)
81
+ finally:
82
+ output.put(None)
83
+
84
+
85
+ def _read_response(output, request_id, timeout):
86
+ deadline = datetime.now().timestamp() + timeout
87
+ while datetime.now().timestamp() < deadline:
88
+ remaining = max(0.01, deadline - datetime.now().timestamp())
89
+ try:
90
+ line = output.get(timeout=remaining)
91
+ except queue.Empty:
92
+ break
93
+ if line is None:
94
+ break
95
+ try:
96
+ message = json.loads(line)
97
+ except (TypeError, json.JSONDecodeError):
98
+ continue
99
+ if message.get("id") == request_id:
100
+ return message
101
+ return None
102
+
103
+
104
+ def _write_json_line(process, payload):
105
+ process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
106
+ process.stdin.flush()
107
+
108
+
109
+ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
110
+ auth_home = session.get("authHome")
111
+ if not auth_home:
112
+ return None
113
+
114
+ env = os.environ.copy()
115
+ env["CODEX_HOME"] = auth_home
116
+ popen_factory = popen_factory or subprocess.Popen
117
+ process = None
118
+ output = queue.Queue()
119
+ try:
120
+ process = popen_factory(
121
+ ["codex", "app-server", "--listen", "stdio://"],
122
+ stdin=subprocess.PIPE,
123
+ stdout=subprocess.PIPE,
124
+ stderr=subprocess.PIPE,
125
+ env=env,
126
+ text=True,
127
+ bufsize=1,
128
+ )
129
+ thread = threading.Thread(target=_reader_thread, args=(process.stdout, output), daemon=True)
130
+ thread.start()
131
+ _write_json_line(process, {
132
+ "jsonrpc": "2.0",
133
+ "id": 1,
134
+ "method": "initialize",
135
+ "params": {
136
+ "clientInfo": {"name": "cdx-manager", "version": "0"},
137
+ "capabilities": {"experimentalApi": True},
138
+ },
139
+ })
140
+ initialized = _read_response(output, 1, timeout)
141
+ if not initialized or initialized.get("error"):
142
+ return None
143
+
144
+ _write_json_line(process, {
145
+ "jsonrpc": "2.0",
146
+ "id": 2,
147
+ "method": "account/rateLimits/read",
148
+ "params": None,
149
+ })
150
+ response = _read_response(output, 2, timeout)
151
+ if not response or response.get("error"):
152
+ return None
153
+ result = response.get("result") or {}
154
+ by_limit = result.get("rateLimitsByLimitId") or {}
155
+ snapshot = by_limit.get("codex") or result.get("rateLimits")
156
+ return normalize_codex_rate_limit_snapshot(snapshot)
157
+ except (OSError, ValueError, BrokenPipeError):
158
+ return None
159
+ finally:
160
+ if process is not None:
161
+ try:
162
+ process.terminate()
163
+ process.wait(timeout=1)
164
+ except (OSError, subprocess.TimeoutExpired):
165
+ try:
166
+ process.kill()
167
+ except OSError:
168
+ pass
@@ -9,6 +9,7 @@ from urllib.parse import quote
9
9
 
10
10
  from .backup_bundle import decode_bundle, encode_bundle
11
11
  from .config import get_cdx_home
12
+ from .codex_usage import fetch_codex_rate_limits
12
13
  from .errors import CdxError
13
14
  from .session_store import create_session_store
14
15
  from .status_source import find_latest_status_artifact
@@ -245,6 +246,7 @@ def create_session_service(options=None):
245
246
  env = options.get("env", os.environ)
246
247
  base_dir = options.get("base_dir") or get_cdx_home(env)
247
248
  store = options.get("store") or create_session_store(base_dir)
249
+ codex_status_fetcher = options.get("fetchCodexRateLimits") or fetch_codex_rate_limits
248
250
 
249
251
  def _get_session_root(name):
250
252
  return os.path.join(base_dir, "profiles", _encode(name))
@@ -551,6 +553,12 @@ def create_session_service(options=None):
551
553
  source_root = session.get("authHome") or _get_session_auth_home(
552
554
  session["name"], session["provider"]
553
555
  )
556
+ if session["provider"] == "codex" and codex_status_fetcher:
557
+ live_status = codex_status_fetcher({**session, "authHome": source_root})
558
+ if live_status:
559
+ record_status(session["name"], live_status)
560
+ return live_status
561
+
554
562
  expected_account_email = (
555
563
  _read_expected_account_email(source_root)
556
564
  if session["provider"] == "codex"
@@ -108,7 +108,7 @@ def _format_status_rows(rows, use_color=False, small=False):
108
108
  _pad_table([headers] + table_rows),
109
109
  "",
110
110
  _style(priority_line, "1", use_color),
111
- _style("Tip: run /status in codex to refresh. Claude sessions auto-refresh; use --refresh to force.", "2", 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),
112
112
  ])
113
113
 
114
114