cdx-manager 0.9.8 → 0.9.9

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.9.8-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.9.9-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -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/*`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
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.9.8"
7
+ version = "0.9.9"
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
@@ -66,7 +66,7 @@ from .status_view import (
66
66
  )
67
67
  from .update_check import check_for_update, check_logics_manager_for_update
68
68
 
69
- VERSION = "0.9.8"
69
+ VERSION = "0.9.9"
70
70
 
71
71
 
72
72
  _COMMAND_HANDLERS = {
@@ -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
@@ -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)
@@ -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 session.get("provider") == PROVIDER_CLAUDE and ttl_seconds == STATUS_CACHE_TTL_SECONDS:
279
- return CLAUDE_STATUS_CACHE_TTL_SECONDS
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