cdx-manager 0.5.0 → 0.5.2
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 +7 -5
- package/changelogs/CHANGELOGS_0_5_1.md +33 -0
- package/changelogs/CHANGELOGS_0_5_2.md +42 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +2 -1
- package/src/cli_commands.py +101 -15
- package/src/cli_render.py +1 -0
- package/src/codex_usage.py +168 -0
- package/src/provider_runtime.py +8 -4
- package/src/session_service.py +8 -0
- package/src/status_view.py +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -37,8 +37,8 @@ One command to launch any session. Zero auth juggling.
|
|
|
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
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
|
-
- **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.**
|
|
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
|
+
- **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.
|
|
129
|
+
CDX_VERSION=v0.5.2 sh install.sh
|
|
129
130
|
```
|
|
130
131
|
|
|
131
132
|
From source:
|
|
@@ -249,6 +250,7 @@ cdx status
|
|
|
249
250
|
| `cdx enable <name> [--json]` | Re-enable a disabled session |
|
|
250
251
|
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
251
252
|
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
253
|
+
| `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used |
|
|
252
254
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
253
255
|
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
254
256
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
@@ -446,7 +448,7 @@ Session names are URL-encoded when used as directory or file names. CLI command
|
|
|
446
448
|
- **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
|
|
447
449
|
- **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
|
|
448
450
|
- **`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
|
|
451
|
+
- **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
452
|
- **`cdx rmv` says "Removal requires confirmation in an interactive terminal"** — pass `--force` to bypass the prompt in non-interactive environments (scripts, CI).
|
|
451
453
|
- **`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
454
|
- **`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`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog (`0.5.1 -> 0.5.2`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-22
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Added transcript-based handoff between two sessions of the same provider.
|
|
8
|
+
- Added automatic launch prompting so the target Codex session reads `shared-context.md` and resumes without a manual instruction.
|
|
9
|
+
- Prepared the package metadata for the 0.5.2 release across npm, PyPI, CLI version output, and README install examples.
|
|
10
|
+
|
|
11
|
+
## Transcript-Based Session Handoff
|
|
12
|
+
|
|
13
|
+
- Added `cdx handoff <source> <target> [--json]`.
|
|
14
|
+
- Builds a shared context from the source session's latest captured launch transcript.
|
|
15
|
+
- Installs the generated context into the target session as `shared-context.md`.
|
|
16
|
+
- Preserves target account authentication and does not copy source credentials.
|
|
17
|
+
- Rejects same-session and cross-provider handoffs with explicit errors.
|
|
18
|
+
- Truncates very large transcripts to the most recent tail before writing the handoff context.
|
|
19
|
+
|
|
20
|
+
## Launch and JSON Output
|
|
21
|
+
|
|
22
|
+
- Passes an initial Codex prompt during handoff launch so the target session is told to read `$CODEX_HOME/shared-context.md`.
|
|
23
|
+
- Adds `launch_prompt` to `cdx handoff ... --json` output for non-interactive integrations.
|
|
24
|
+
- Keeps `--json` handoff mode non-launching while exposing source transcript, generated context, and installed target context paths.
|
|
25
|
+
|
|
26
|
+
## Documentation
|
|
27
|
+
|
|
28
|
+
- Updated CLI help and session-list command hints for `cdx handoff <source> <target>`.
|
|
29
|
+
- Updated the README feature summary and command table for transcript-based handoff.
|
|
30
|
+
- Updated the README badge and pinned installer example to `v0.5.2`.
|
|
31
|
+
|
|
32
|
+
## Validation and Regression Coverage
|
|
33
|
+
|
|
34
|
+
- Added CLI coverage for source-to-target handoff context generation.
|
|
35
|
+
- Added CLI coverage proving non-JSON handoff launches the target Codex session with the resume prompt.
|
|
36
|
+
- Kept full Python test coverage and Logics lint green.
|
|
37
|
+
|
|
38
|
+
## Validation and Regression Evidence
|
|
39
|
+
|
|
40
|
+
- `python -m pytest test -q`
|
|
41
|
+
- `python logics/skills/logics.py lint --require-status`
|
|
42
|
+
- `git diff --check`
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
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.
|
|
52
|
+
VERSION = "0.5.2"
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
# ---------------------------------------------------------------------------
|
|
@@ -68,6 +68,7 @@ def _print_help(use_color=False):
|
|
|
68
68
|
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
69
69
|
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
70
70
|
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
71
|
+
f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
|
|
71
72
|
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
72
73
|
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
73
74
|
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
package/src/cli_commands.py
CHANGED
|
@@ -43,8 +43,9 @@ UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
|
43
43
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
44
44
|
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
45
45
|
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
46
|
-
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json]"
|
|
46
|
+
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
47
47
|
API_SCHEMA_VERSION = 1
|
|
48
|
+
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def _local_now_iso():
|
|
@@ -67,6 +68,56 @@ def _write_json(ctx, payload):
|
|
|
67
68
|
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def _latest_launch_transcript_path(session):
|
|
72
|
+
paths = _list_launch_transcript_paths(session)
|
|
73
|
+
if not paths:
|
|
74
|
+
return None
|
|
75
|
+
return max(paths, key=lambda path: (os.path.getmtime(path), path))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_handoff_transcript(path):
|
|
79
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
80
|
+
content = handle.read()
|
|
81
|
+
if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
|
|
82
|
+
return content, False
|
|
83
|
+
return content[-HANDOFF_TRANSCRIPT_CHARS:], True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_handoff_context(source, target, transcript_path, transcript, truncated=False):
|
|
87
|
+
truncation_note = (
|
|
88
|
+
"Only the tail of the previous transcript is included because the log was large."
|
|
89
|
+
if truncated else
|
|
90
|
+
"The previous transcript is included below."
|
|
91
|
+
)
|
|
92
|
+
return "\n".join([
|
|
93
|
+
"# Shared Context",
|
|
94
|
+
"",
|
|
95
|
+
"## Goal",
|
|
96
|
+
f"Resume the work from `{source['name']}` in `{target['name']}` because the source account has no usage left.",
|
|
97
|
+
"",
|
|
98
|
+
"## Current State",
|
|
99
|
+
f"- Source session: `{source['name']}` ({source['provider']})",
|
|
100
|
+
f"- Target session: `{target['name']}` ({target['provider']})",
|
|
101
|
+
f"- Transcript source: `{transcript_path}`",
|
|
102
|
+
f"- {truncation_note}",
|
|
103
|
+
"",
|
|
104
|
+
"## Previous Session Transcript",
|
|
105
|
+
transcript.rstrip(),
|
|
106
|
+
"",
|
|
107
|
+
"## Next Steps",
|
|
108
|
+
"- Read the transcript above.",
|
|
109
|
+
"- Continue the task from the latest actionable state.",
|
|
110
|
+
"- Preserve the target account authentication; do not copy source credentials.",
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _handoff_launch_prompt():
|
|
115
|
+
return (
|
|
116
|
+
"Read $CODEX_HOME/shared-context.md first, then resume the previous session "
|
|
117
|
+
"from the latest actionable state. Do not ask me to paste the context again."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
70
121
|
def _parse_json_flag(args):
|
|
71
122
|
json_flag = "--json" in args
|
|
72
123
|
cleaned = [arg for arg in args if arg != "--json"]
|
|
@@ -914,7 +965,7 @@ def handle_update(rest, ctx):
|
|
|
914
965
|
return 0
|
|
915
966
|
|
|
916
967
|
|
|
917
|
-
def handle_launch(command, ctx):
|
|
968
|
+
def handle_launch(command, ctx, initial_prompt=None):
|
|
918
969
|
json_flag = "--json" in ctx["options"].get("raw_args", [])
|
|
919
970
|
update_notice = ctx.get("update_notice")
|
|
920
971
|
warnings = []
|
|
@@ -946,10 +997,10 @@ def handle_launch(command, ctx):
|
|
|
946
997
|
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
947
998
|
if session["provider"] == "codex":
|
|
948
999
|
if not json_flag:
|
|
949
|
-
ctx["out"](f"{_dim('Tip:
|
|
1000
|
+
ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
|
|
950
1001
|
_run_interactive_provider_command(
|
|
951
1002
|
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
952
|
-
signal_emitter=ctx.get("signal_emitter")
|
|
1003
|
+
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
|
953
1004
|
)
|
|
954
1005
|
if json_flag:
|
|
955
1006
|
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
@@ -958,22 +1009,57 @@ def handle_launch(command, ctx):
|
|
|
958
1009
|
|
|
959
1010
|
def handle_handoff(rest, ctx):
|
|
960
1011
|
json_flag, args = _parse_json_flag(rest)
|
|
961
|
-
if len(args)
|
|
1012
|
+
if len(args) not in (1, 2):
|
|
962
1013
|
raise CdxError(HANDOFF_USAGE)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1014
|
+
if len(args) == 1:
|
|
1015
|
+
name = args[0]
|
|
1016
|
+
session = ctx["service"]["get_session"](name)
|
|
1017
|
+
if not session:
|
|
1018
|
+
raise CdxError(f"Unknown session: {name}")
|
|
1019
|
+
install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
|
|
1020
|
+
if json_flag:
|
|
1021
|
+
_write_json(ctx, _json_success(
|
|
1022
|
+
"handoff",
|
|
1023
|
+
f"Installed shared context for {name}",
|
|
1024
|
+
context=install,
|
|
1025
|
+
launch_prompt=_handoff_launch_prompt(),
|
|
1026
|
+
session=session,
|
|
1027
|
+
))
|
|
1028
|
+
return 0
|
|
1029
|
+
text = f"Shared context installed for {name}: {install['target_path']}"
|
|
1030
|
+
ctx["out"](f"{_info(text, ctx['use_color'])}\n")
|
|
1031
|
+
return handle_launch(name, ctx, initial_prompt=_handoff_launch_prompt())
|
|
1032
|
+
|
|
1033
|
+
source_name, target_name = args
|
|
1034
|
+
if source_name == target_name:
|
|
1035
|
+
raise CdxError("Source and target sessions must be different")
|
|
1036
|
+
source = ctx["service"]["get_session"](source_name)
|
|
1037
|
+
if not source:
|
|
1038
|
+
raise CdxError(f"Unknown session: {source_name}")
|
|
1039
|
+
target = ctx["service"]["get_session"](target_name)
|
|
1040
|
+
if not target:
|
|
1041
|
+
raise CdxError(f"Unknown session: {target_name}")
|
|
1042
|
+
if source["provider"] != target["provider"]:
|
|
1043
|
+
raise CdxError("Source and target sessions must use the same provider for handoff.")
|
|
1044
|
+
transcript_path = _latest_launch_transcript_path(source)
|
|
1045
|
+
if not transcript_path:
|
|
1046
|
+
raise CdxError(f"No launch transcript found for session: {source_name}")
|
|
1047
|
+
transcript, truncated = _read_handoff_transcript(transcript_path)
|
|
1048
|
+
context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
|
|
1049
|
+
write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
|
|
1050
|
+
install = install_context_for_session(ctx["service"]["base_dir"], target, ctx.get("cwd"))
|
|
968
1051
|
if json_flag:
|
|
969
1052
|
_write_json(ctx, _json_success(
|
|
970
1053
|
"handoff",
|
|
971
|
-
f"
|
|
1054
|
+
f"Prepared handoff from {source_name} to {target_name}",
|
|
972
1055
|
context=install,
|
|
973
|
-
|
|
1056
|
+
source_session=source,
|
|
1057
|
+
target_session=target,
|
|
1058
|
+
source_transcript=transcript_path,
|
|
1059
|
+
shared_context=write_result,
|
|
1060
|
+
launch_prompt=_handoff_launch_prompt(),
|
|
974
1061
|
))
|
|
975
1062
|
return 0
|
|
976
|
-
text = f"
|
|
1063
|
+
text = f"Handoff prepared from {source_name} to {target_name}: {install['target_path']}"
|
|
977
1064
|
ctx["out"](f"{_info(text, ctx['use_color'])}\n")
|
|
978
|
-
|
|
979
|
-
return handle_launch(name, ctx)
|
|
1065
|
+
return handle_launch(target_name, ctx, initial_prompt=_handoff_launch_prompt())
|
package/src/cli_render.py
CHANGED
|
@@ -129,6 +129,7 @@ def _format_sessions(service, use_color=False):
|
|
|
129
129
|
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
130
130
|
f" {_style('cdx context show', '36', use_color)}",
|
|
131
131
|
f" {_style('cdx handoff <name>', '36', use_color)}",
|
|
132
|
+
f" {_style('cdx handoff <source> <target>', '36', use_color)}",
|
|
132
133
|
f" {_style('cdx disable <name>', '36', use_color)}",
|
|
133
134
|
f" {_style('cdx enable <name>', '36', use_color)}",
|
|
134
135
|
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
@@ -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
|
package/src/provider_runtime.py
CHANGED
|
@@ -98,7 +98,7 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
def _build_launch_spec(session, cwd=None, env_override=None):
|
|
101
|
+
def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
|
|
102
102
|
cwd = cwd or os.getcwd()
|
|
103
103
|
env_override = env_override or {}
|
|
104
104
|
env = {**os.environ, **env_override}
|
|
@@ -112,9 +112,12 @@ def _build_launch_spec(session, cwd=None, env_override=None):
|
|
|
112
112
|
},
|
|
113
113
|
"label": "claude",
|
|
114
114
|
}
|
|
115
|
+
args = ["--no-alt-screen", "--cd", cwd]
|
|
116
|
+
if initial_prompt:
|
|
117
|
+
args.append(initial_prompt)
|
|
115
118
|
return _wrap_launch_with_transcript(session, {
|
|
116
119
|
"command": "codex",
|
|
117
|
-
"args":
|
|
120
|
+
"args": args,
|
|
118
121
|
"options": {
|
|
119
122
|
"env": {**env, "CODEX_HOME": _get_auth_home(session)},
|
|
120
123
|
},
|
|
@@ -221,10 +224,11 @@ def _signal_name(sig):
|
|
|
221
224
|
|
|
222
225
|
|
|
223
226
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
224
|
-
env_override=None, signal_emitter=None
|
|
227
|
+
env_override=None, signal_emitter=None,
|
|
228
|
+
initial_prompt=None):
|
|
225
229
|
spawn = spawn or subprocess.Popen
|
|
226
230
|
spec = (
|
|
227
|
-
_build_launch_spec(session, cwd=cwd, env_override=env_override)
|
|
231
|
+
_build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|
|
228
232
|
if action == "launch"
|
|
229
233
|
else _build_auth_action_spec(session, action, cwd=cwd, env_override=env_override)
|
|
230
234
|
)
|
package/src/session_service.py
CHANGED
|
@@ -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"
|
package/src/status_view.py
CHANGED
|
@@ -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:
|
|
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
|
|