cdx-manager 0.9.8 → 0.9.10
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/changelogs/CHANGELOGS_0_9_10.md +33 -0
- package/changelogs/CHANGELOGS_0_9_9.md +36 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_commands.py +70 -17
- package/src/codex_usage.py +53 -0
- package/src/provider_runtime.py +16 -0
- package/src/session_service.py +8 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# CDX Manager 0.9.10
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Clarified Claude status warnings when quota refresh fails but local Claude auth is still valid.
|
|
6
|
+
|
|
7
|
+
## Changes
|
|
8
|
+
|
|
9
|
+
### Claude status diagnostics
|
|
10
|
+
|
|
11
|
+
`cdx status` now distinguishes a Claude quota refresh failure from a local Claude login failure.
|
|
12
|
+
|
|
13
|
+
When Anthropic quota probing returns an invalid-credentials response while `claude auth status` still reports an authenticated session, the JSON warning keeps the existing `claude_refresh_failed` code for compatibility and adds:
|
|
14
|
+
|
|
15
|
+
- `auth_status: authenticated`
|
|
16
|
+
- `status_freshness: stale`
|
|
17
|
+
- a message explaining that cached quota values may be stale
|
|
18
|
+
|
|
19
|
+
The text-mode warning now says that local auth is still valid instead of implying that the Claude account itself is down.
|
|
20
|
+
|
|
21
|
+
Regression coverage verifies both the JSON payload and terminal warning for this mixed auth/quota-refresh state.
|
|
22
|
+
|
|
23
|
+
## Validation
|
|
24
|
+
|
|
25
|
+
- `npm run release:validate`
|
|
26
|
+
- `npm run lint`
|
|
27
|
+
- `npm test`
|
|
28
|
+
- `logics-manager lint --require-status`
|
|
29
|
+
- `logics-manager audit`
|
|
30
|
+
- `git diff --check`
|
|
31
|
+
- `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
|
|
32
|
+
- `python -m build`
|
|
33
|
+
- `python -m twine check dist/*`
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# CDX Manager 0.9.9
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Fixed Codex session logouts caused by concurrent OAuth token refresh races.
|
|
6
|
+
- Refreshed the local Logics assistant bridge and canonical workflow instructions.
|
|
7
|
+
|
|
8
|
+
## Changes
|
|
9
|
+
|
|
10
|
+
### Codex token refresh stability
|
|
11
|
+
|
|
12
|
+
Codex session startup now serializes OAuth token refresh work per source session and reuses a freshly refreshed source token when multiple sessions start at the same time.
|
|
13
|
+
|
|
14
|
+
This prevents parallel launches from copying stale credentials into isolated Codex profiles while another launch is already rotating the shared token.
|
|
15
|
+
|
|
16
|
+
### Runtime observability
|
|
17
|
+
|
|
18
|
+
The provider runtime now recognizes Codex refresh-rate errors as refresh failures, making status and failure reporting more precise when token refresh is blocked or rate-limited.
|
|
19
|
+
|
|
20
|
+
Regression coverage exercises concurrent Codex launches and verifies that all isolated profiles receive the refreshed token.
|
|
21
|
+
|
|
22
|
+
### Logics workflow instructions
|
|
23
|
+
|
|
24
|
+
The local `LOGICS.md` bridge and `logics/instructions.md` were refreshed to keep release and workflow operations aligned with the current `logics-manager` commands.
|
|
25
|
+
|
|
26
|
+
## Validation
|
|
27
|
+
|
|
28
|
+
- `npm run release:validate`
|
|
29
|
+
- `npm run lint`
|
|
30
|
+
- `npm test`
|
|
31
|
+
- `logics-manager lint --require-status`
|
|
32
|
+
- `logics-manager audit`
|
|
33
|
+
- `git diff --check`
|
|
34
|
+
- `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
|
|
35
|
+
- `python -m build`
|
|
36
|
+
- `python -m twine check dist/*`
|
|
@@ -100,6 +100,10 @@
|
|
|
100
100
|
"v0.9.8": {
|
|
101
101
|
"github_tarball_sha256": "8f05d052b7890d74de7ac510c20376430515ad2616e139941cb9bb8edc4fb680",
|
|
102
102
|
"github_zip_sha256": "3bc3834ecf84784c77cf603fcd4f6f439e42005612f82fabab588886519179eb"
|
|
103
|
+
},
|
|
104
|
+
"v0.9.9": {
|
|
105
|
+
"github_tarball_sha256": "092c564803dd9a674250040ac308055de3701aa993237f257676403c24eb1318",
|
|
106
|
+
"github_zip_sha256": "0fbd8de7a97cde5aef14f23036def2e34dd690c2aa78ef93ddfeef0bb3e9547b"
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
package/src/cli_commands.py
CHANGED
|
@@ -2533,16 +2533,6 @@ def handle_status(rest, ctx):
|
|
|
2533
2533
|
}
|
|
2534
2534
|
for item in refresh_result.get("errors", [])
|
|
2535
2535
|
]
|
|
2536
|
-
warnings.extend([
|
|
2537
|
-
{
|
|
2538
|
-
"code": "claude_refresh_failed",
|
|
2539
|
-
"session": item.get("session") or "unknown",
|
|
2540
|
-
"message": item.get("error") or "unknown error",
|
|
2541
|
-
}
|
|
2542
|
-
for item in refresh_errors
|
|
2543
|
-
])
|
|
2544
|
-
warnings.extend(_update_notice_warnings(ctx))
|
|
2545
|
-
|
|
2546
2536
|
status_progress = None if parsed["json"] else _make_status_progress(ctx)
|
|
2547
2537
|
if len(args) == 1:
|
|
2548
2538
|
row = ctx["service"]["get_status_row"](
|
|
@@ -2552,11 +2542,16 @@ def handle_status(rest, ctx):
|
|
|
2552
2542
|
cache_only=parsed["cached"],
|
|
2553
2543
|
status_timeout_seconds=parsed["timeout"],
|
|
2554
2544
|
)
|
|
2545
|
+
output_warnings = [
|
|
2546
|
+
*warnings,
|
|
2547
|
+
*_refresh_warning_payloads(refresh_errors, [row]),
|
|
2548
|
+
*_update_notice_warnings(ctx),
|
|
2549
|
+
]
|
|
2555
2550
|
if parsed["json"]:
|
|
2556
|
-
_write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=
|
|
2551
|
+
_write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=output_warnings, session=row))
|
|
2557
2552
|
return 0
|
|
2558
2553
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
2559
|
-
_write_refresh_warnings(refresh_errors, ctx)
|
|
2554
|
+
_write_refresh_warnings(refresh_errors, ctx, rows=[row])
|
|
2560
2555
|
_write_update_notice(ctx)
|
|
2561
2556
|
return 0
|
|
2562
2557
|
|
|
@@ -2566,11 +2561,16 @@ def handle_status(rest, ctx):
|
|
|
2566
2561
|
cache_only=parsed["cached"],
|
|
2567
2562
|
status_timeout_seconds=parsed["timeout"],
|
|
2568
2563
|
)
|
|
2564
|
+
output_warnings = [
|
|
2565
|
+
*warnings,
|
|
2566
|
+
*_refresh_warning_payloads(refresh_errors, rows),
|
|
2567
|
+
*_update_notice_warnings(ctx),
|
|
2568
|
+
]
|
|
2569
2569
|
if parsed["json"]:
|
|
2570
|
-
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=
|
|
2570
|
+
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=output_warnings, rows=rows))
|
|
2571
2571
|
return 0
|
|
2572
2572
|
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
|
|
2573
|
-
_write_refresh_warnings(refresh_errors, ctx)
|
|
2573
|
+
_write_refresh_warnings(refresh_errors, ctx, rows=rows)
|
|
2574
2574
|
_write_update_notice(ctx)
|
|
2575
2575
|
return 0
|
|
2576
2576
|
|
|
@@ -2607,12 +2607,65 @@ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env
|
|
|
2607
2607
|
return {"updated": updated, "errors": errors}
|
|
2608
2608
|
|
|
2609
2609
|
|
|
2610
|
-
def
|
|
2611
|
-
|
|
2610
|
+
def _rows_by_session(rows):
|
|
2611
|
+
return {
|
|
2612
|
+
row.get("session_name"): row
|
|
2613
|
+
for row in (rows or [])
|
|
2614
|
+
if row.get("session_name")
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
def _is_invalid_claude_usage_auth(error):
|
|
2619
|
+
text = str(error or "").lower()
|
|
2620
|
+
return "http 401" in text and "invalid authentication credentials" in text
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def _has_valid_local_claude_auth(item, rows_by_name):
|
|
2624
|
+
session = item.get("session")
|
|
2625
|
+
row = rows_by_name.get(session)
|
|
2626
|
+
return bool(row and row.get("provider") == PROVIDER_CLAUDE and row.get("auth_status") == "authenticated")
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
def _refresh_warning_payloads(refresh_errors, rows=None):
|
|
2630
|
+
rows_by_name = _rows_by_session(rows)
|
|
2631
|
+
warnings = []
|
|
2612
2632
|
for item in refresh_errors:
|
|
2613
2633
|
session = item.get("session") or "unknown"
|
|
2614
2634
|
error = item.get("error") or "unknown error"
|
|
2615
|
-
|
|
2635
|
+
warning = {
|
|
2636
|
+
"code": "claude_refresh_failed",
|
|
2637
|
+
"session": session,
|
|
2638
|
+
"message": error,
|
|
2639
|
+
}
|
|
2640
|
+
if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
|
|
2641
|
+
warning.update({
|
|
2642
|
+
"auth_status": "authenticated",
|
|
2643
|
+
"status_freshness": "stale",
|
|
2644
|
+
"message": (
|
|
2645
|
+
"Claude quota refresh failed, but local Claude auth is still valid; "
|
|
2646
|
+
f"cached quota may be stale. Original error: {error}"
|
|
2647
|
+
),
|
|
2648
|
+
})
|
|
2649
|
+
warnings.append(warning)
|
|
2650
|
+
return warnings
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
def _format_refresh_warning(item, rows_by_name):
|
|
2654
|
+
session = item.get("session") or "unknown"
|
|
2655
|
+
error = item.get("error") or "unknown error"
|
|
2656
|
+
if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
|
|
2657
|
+
return (
|
|
2658
|
+
f"Warning: Claude quota refresh failed for {session}, but local auth is still valid; "
|
|
2659
|
+
f"cached quota may be stale. ({error})"
|
|
2660
|
+
)
|
|
2661
|
+
return f"Warning: Claude refresh failed for {session}: {error}"
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
|
|
2665
|
+
write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
|
|
2666
|
+
rows_by_name = _rows_by_session(rows)
|
|
2667
|
+
for item in refresh_errors:
|
|
2668
|
+
write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")
|
|
2616
2669
|
|
|
2617
2670
|
|
|
2618
2671
|
def handle_export(rest, ctx):
|
package/src/codex_usage.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import queue
|
|
@@ -5,6 +6,49 @@ import subprocess
|
|
|
5
6
|
import threading
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
|
|
9
|
+
try:
|
|
10
|
+
import fcntl
|
|
11
|
+
except ImportError: # pragma: no cover - non-POSIX platforms
|
|
12
|
+
fcntl = None
|
|
13
|
+
|
|
14
|
+
CODEX_AUTH_LOCK_NAME = ".cdx-auth.lock"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextlib.contextmanager
|
|
18
|
+
def codex_auth_lock(auth_home, blocking=False):
|
|
19
|
+
"""Serialize codex token refreshes per CODEX_HOME.
|
|
20
|
+
|
|
21
|
+
Codex rotates its OAuth refresh_token on refresh and invalidates the old
|
|
22
|
+
one. If a status probe and an interactive session refresh the same
|
|
23
|
+
auth.json concurrently, one rotates the token from under the other and that
|
|
24
|
+
session gets logged out. This flock makes them take turns. Yields True when
|
|
25
|
+
the lock is held, False when a non-blocking acquire found it already taken.
|
|
26
|
+
ponytail: POSIX flock only; on Windows (no fcntl) this is a no-op.
|
|
27
|
+
"""
|
|
28
|
+
if not auth_home or fcntl is None:
|
|
29
|
+
yield True
|
|
30
|
+
return
|
|
31
|
+
try:
|
|
32
|
+
os.makedirs(auth_home, exist_ok=True)
|
|
33
|
+
handle = open(os.path.join(auth_home, CODEX_AUTH_LOCK_NAME), "w")
|
|
34
|
+
except OSError:
|
|
35
|
+
yield True
|
|
36
|
+
return
|
|
37
|
+
flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
|
|
38
|
+
try:
|
|
39
|
+
fcntl.flock(handle, flags)
|
|
40
|
+
except OSError:
|
|
41
|
+
handle.close()
|
|
42
|
+
yield False
|
|
43
|
+
return
|
|
44
|
+
try:
|
|
45
|
+
yield True
|
|
46
|
+
finally:
|
|
47
|
+
try:
|
|
48
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
49
|
+
finally:
|
|
50
|
+
handle.close()
|
|
51
|
+
|
|
8
52
|
MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
9
53
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
10
54
|
|
|
@@ -110,7 +154,16 @@ def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
|
|
|
110
154
|
auth_home = session.get("authHome")
|
|
111
155
|
if not auth_home:
|
|
112
156
|
return {"ok": False, "reason": "missing_auth_home", "status": None}
|
|
157
|
+
# Back off to cached status rather than racing an interactive session's
|
|
158
|
+
# token refresh (which would log it out). The launcher holds this lock for
|
|
159
|
+
# the whole session, so a busy account simply skips the live probe.
|
|
160
|
+
with codex_auth_lock(auth_home) as acquired:
|
|
161
|
+
if not acquired:
|
|
162
|
+
return {"ok": False, "reason": "auth_locked", "status": None}
|
|
163
|
+
return _probe_codex_rate_limit_diagnostic(session, auth_home, timeout, popen_factory)
|
|
164
|
+
|
|
113
165
|
|
|
166
|
+
def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_factory=None):
|
|
114
167
|
env = os.environ.copy()
|
|
115
168
|
env["CODEX_HOME"] = auth_home
|
|
116
169
|
popen_factory = popen_factory or subprocess.Popen
|
package/src/provider_runtime.py
CHANGED
|
@@ -10,6 +10,7 @@ import uuid
|
|
|
10
10
|
import base64
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
|
|
13
|
+
from .codex_usage import codex_auth_lock
|
|
13
14
|
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
14
15
|
from .errors import CdxError
|
|
15
16
|
|
|
@@ -929,6 +930,21 @@ def _signal_name(sig):
|
|
|
929
930
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
930
931
|
env_override=None, signal_emitter=None,
|
|
931
932
|
initial_prompt=None, lifecycle_callback=None):
|
|
933
|
+
kwargs = dict(spawn=spawn, cwd=cwd, env_override=env_override,
|
|
934
|
+
signal_emitter=signal_emitter, initial_prompt=initial_prompt,
|
|
935
|
+
lifecycle_callback=lifecycle_callback)
|
|
936
|
+
if session.get("provider") != PROVIDER_CODEX:
|
|
937
|
+
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
938
|
+
# Hold the per-CODEX_HOME lock for the whole session so a concurrent status
|
|
939
|
+
# probe backs off instead of rotating the refresh_token mid-run (which logs
|
|
940
|
+
# the session out). ponytail: non-blocking, so launch never waits on a probe.
|
|
941
|
+
with codex_auth_lock(_get_auth_home(session)):
|
|
942
|
+
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _run_interactive_provider_command_impl(session, action, spawn=None, cwd=None,
|
|
946
|
+
env_override=None, signal_emitter=None,
|
|
947
|
+
initial_prompt=None, lifecycle_callback=None):
|
|
932
948
|
spawn = spawn or subprocess.Popen
|
|
933
949
|
if action == "launch":
|
|
934
950
|
spec = _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|
package/src/session_service.py
CHANGED
|
@@ -68,6 +68,9 @@ RESERVED_SESSION_NAMES = {
|
|
|
68
68
|
}
|
|
69
69
|
STATUS_CACHE_TTL_SECONDS = 60
|
|
70
70
|
CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
|
|
71
|
+
# Each live Codex probe spawns app-server, which can refresh+rotate the OAuth
|
|
72
|
+
# token. Cache longer so routine status calls don't keep triggering refreshes.
|
|
73
|
+
CODEX_STATUS_CACHE_TTL_SECONDS = 5 * 60
|
|
71
74
|
STATUS_PROBE_TIMEOUT_SECONDS = 5
|
|
72
75
|
MAX_STATUS_WORKERS = 8
|
|
73
76
|
LAUNCH_POWER_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
|
|
@@ -275,8 +278,11 @@ def _parse_status_timestamp(value):
|
|
|
275
278
|
|
|
276
279
|
|
|
277
280
|
def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
278
|
-
if
|
|
279
|
-
|
|
281
|
+
if ttl_seconds == STATUS_CACHE_TTL_SECONDS:
|
|
282
|
+
if session.get("provider") == PROVIDER_CLAUDE:
|
|
283
|
+
return CLAUDE_STATUS_CACHE_TTL_SECONDS
|
|
284
|
+
if session.get("provider") == PROVIDER_CODEX:
|
|
285
|
+
return CODEX_STATUS_CACHE_TTL_SECONDS
|
|
280
286
|
return ttl_seconds
|
|
281
287
|
|
|
282
288
|
|