cdx-manager 0.5.2 → 0.5.4
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 +3 -3
- package/changelogs/CHANGELOGS_0_5_3.md +42 -0
- package/changelogs/CHANGELOGS_0_5_4.md +49 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +36 -19
- package/src/cli.py +1 -1
- package/src/cli_commands.py +126 -160
- package/src/codex_usage.py +36 -7
- package/src/config.py +4 -0
- package/src/notify.py +25 -1
- package/src/provider_runtime.py +23 -13
- package/src/session_service.py +17 -10
- package/src/status_source.py +22 -20
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
|
|
|
@@ -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.
|
|
129
|
+
CDX_VERSION=v0.5.4 sh install.sh
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
From source:
|
|
@@ -250,7 +250,7 @@ cdx status
|
|
|
250
250
|
| `cdx enable <name> [--json]` | Re-enable a disabled session |
|
|
251
251
|
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
252
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 |
|
|
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; supports Codex and Claude targets, including cross-provider handoff |
|
|
254
254
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
255
255
|
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
256
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 |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog (`0.5.2 -> 0.5.3`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-23
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Added Claude-compatible handoff launch prompting so Claude targets are told exactly which `shared-context.md` file to read.
|
|
8
|
+
- Added Claude launch transcript capture, enabling transcript-based handoff from Claude source sessions.
|
|
9
|
+
- Enabled cross-provider transcript handoff between Codex and Claude sessions.
|
|
10
|
+
- Prepared the package metadata for the 0.5.3 release across npm, PyPI, CLI version output, and README install examples.
|
|
11
|
+
|
|
12
|
+
## Claude Handoff
|
|
13
|
+
|
|
14
|
+
- Passes the handoff prompt to Claude as the initial prompt argument during launch.
|
|
15
|
+
- Installs shared context into the target Claude session profile and references the concrete `shared-context.md` path in the prompt.
|
|
16
|
+
- Captures Claude launch transcripts under the session's `claude-home/log/` directory using the same transcript wrapper path as Codex.
|
|
17
|
+
- Preserves Claude `HOME` isolation while running through the transcript wrapper.
|
|
18
|
+
|
|
19
|
+
## Cross-Provider Handoff
|
|
20
|
+
|
|
21
|
+
- Allows `cdx handoff <source> <target>` when the source and target providers differ.
|
|
22
|
+
- Keeps generated handoff context provider-neutral while still recording source and target provider metadata.
|
|
23
|
+
- Preserves target account authentication and does not copy source credentials.
|
|
24
|
+
|
|
25
|
+
## Documentation
|
|
26
|
+
|
|
27
|
+
- Updated the command table to document Codex and Claude handoff targets, including cross-provider handoff.
|
|
28
|
+
- Updated the README badge and pinned installer example to `v0.5.3`.
|
|
29
|
+
|
|
30
|
+
## Validation and Regression Coverage
|
|
31
|
+
|
|
32
|
+
- Added CLI coverage for Claude-to-Claude transcript handoff.
|
|
33
|
+
- Added CLI coverage for Codex-to-Claude handoff.
|
|
34
|
+
- Added CLI coverage proving non-JSON Claude handoff launches Claude with the resume prompt.
|
|
35
|
+
- Updated existing Claude launch coverage to verify transcript-wrapped launches.
|
|
36
|
+
|
|
37
|
+
## Validation and Regression Evidence
|
|
38
|
+
|
|
39
|
+
- `python3 -m pytest test/test_cli_py.py test/test_runtime_py.py`
|
|
40
|
+
- `python3 -m pytest`
|
|
41
|
+
- `npm run lint`
|
|
42
|
+
- `npm test`
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog (`0.5.3 -> 0.5.4`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-23
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Added a Logics ADR for the code quality, security, performance, and test coverage improvements completed after `0.5.3`.
|
|
8
|
+
- Hardened bundle import path handling and launch prompt validation without changing CLI contracts or on-disk formats.
|
|
9
|
+
- Improved status parsing and provider handling internals for safer future maintenance.
|
|
10
|
+
- Added structured Codex app-server probe diagnostics while keeping the existing status API behavior compatible.
|
|
11
|
+
|
|
12
|
+
## Security and Runtime Hardening
|
|
13
|
+
|
|
14
|
+
- Hardened bundle relative path validation against traversal, absolute paths, and Windows drive paths.
|
|
15
|
+
- Validated provider launch `initial_prompt` type and length before it is passed into subprocess arguments.
|
|
16
|
+
- Replaced mutable signal-capture state with explicit `nonlocal` signal forwarding.
|
|
17
|
+
- Added Linux `notify-send` support with backend timeout handling and shell-free notification arguments.
|
|
18
|
+
|
|
19
|
+
## Status and Provider Internals
|
|
20
|
+
|
|
21
|
+
- Moved status extraction regex compilation to module-level constants.
|
|
22
|
+
- Added shared provider constants for Codex and Claude identity checks.
|
|
23
|
+
- Improved Claude status refresh handling for sync and async refresh functions without per-thread event loops.
|
|
24
|
+
- Added `fetch_codex_rate_limit_diagnostic()` to expose structured failure reasons such as missing auth home, initialize failure, app-server read failure, missing rate limits, and missing Codex CLI.
|
|
25
|
+
|
|
26
|
+
## CLI Parser Maintenance
|
|
27
|
+
|
|
28
|
+
- Factorized more CLI flag parsing through a shared flat-flag parser.
|
|
29
|
+
- Added `--flag=value` support where appropriate, including `cdx notify --poll=<seconds>`.
|
|
30
|
+
- Preserved existing usage errors and JSON/non-JSON output contracts.
|
|
31
|
+
|
|
32
|
+
## Documentation
|
|
33
|
+
|
|
34
|
+
- Added `logics/architecture/adr_001_code_quality_and_security_improvements.md` documenting the security, test, quality, and performance decisions.
|
|
35
|
+
- Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.4`.
|
|
36
|
+
|
|
37
|
+
## Validation and Regression Coverage
|
|
38
|
+
|
|
39
|
+
- Added direct session-store rollback tests for add, remove, rename, and replace failure paths.
|
|
40
|
+
- Added status-source tests for key-value, Codex block, Claude block, artifact selection, reset timestamp parsing, and safe relative paths.
|
|
41
|
+
- Added notification tests for macOS, Linux, backend fallback, timeout, and argument handling.
|
|
42
|
+
- Added CLI parser tests for update, status, repair, export/import subsets, corrupt bundles, and clean behavior with and without logs.
|
|
43
|
+
- Added Codex app-server diagnostic tests for success and failure cases.
|
|
44
|
+
|
|
45
|
+
## Validation and Regression Evidence
|
|
46
|
+
|
|
47
|
+
- `npm test`
|
|
48
|
+
- `npm run lint`
|
|
49
|
+
- `python logics/skills/logics.py lint --require-status`
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/claude_refresh.py
CHANGED
|
@@ -3,6 +3,7 @@ import threading
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
|
|
5
5
|
from .claude_usage import refresh_claude_session_status
|
|
6
|
+
from .config import PROVIDER_CLAUDE
|
|
6
7
|
from .errors import CdxError
|
|
7
8
|
|
|
8
9
|
CLAUDE_REFRESH_TTL_SECONDS = 10 * 60
|
|
@@ -35,7 +36,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
|
|
|
35
36
|
sessions = service["list_sessions"]()
|
|
36
37
|
claude_sessions = [
|
|
37
38
|
s for s in sessions
|
|
38
|
-
if s["provider"] ==
|
|
39
|
+
if s["provider"] == PROVIDER_CLAUDE
|
|
39
40
|
and s.get("enabled", True) is not False
|
|
40
41
|
and (not target_names or s["name"] in target_names)
|
|
41
42
|
and (force or _is_stale(s, ttl_seconds=ttl_seconds))
|
|
@@ -45,25 +46,41 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
|
|
|
45
46
|
|
|
46
47
|
errors = []
|
|
47
48
|
results = {}
|
|
48
|
-
threads = []
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
50
|
+
if inspect.iscoroutinefunction(refresh_fn):
|
|
51
|
+
import asyncio
|
|
52
|
+
|
|
53
|
+
async def fetch_all():
|
|
54
|
+
async def fetch_async(s):
|
|
55
|
+
try:
|
|
56
|
+
usage = await refresh_fn(s)
|
|
57
|
+
if usage:
|
|
58
|
+
results[s["name"]] = usage
|
|
59
|
+
except Exception as e:
|
|
60
|
+
errors.append({"session": s["name"], "error": e})
|
|
61
|
+
|
|
62
|
+
await asyncio.gather(*(fetch_async(s) for s in claude_sessions))
|
|
63
|
+
|
|
64
|
+
asyncio.run(fetch_all())
|
|
65
|
+
else:
|
|
66
|
+
threads = []
|
|
67
|
+
|
|
68
|
+
def fetch(s):
|
|
69
|
+
try:
|
|
70
|
+
usage = refresh_fn(s)
|
|
71
|
+
if inspect.isawaitable(usage):
|
|
72
|
+
raise CdxError("Claude refresh function returned an awaitable from a sync callable.")
|
|
73
|
+
if usage:
|
|
74
|
+
results[s["name"]] = usage
|
|
75
|
+
except Exception as e:
|
|
76
|
+
errors.append({"session": s["name"], "error": e})
|
|
77
|
+
|
|
78
|
+
for s in claude_sessions:
|
|
79
|
+
t = threading.Thread(target=fetch, args=(s,), daemon=True)
|
|
80
|
+
threads.append(t)
|
|
81
|
+
t.start()
|
|
82
|
+
for t in threads:
|
|
83
|
+
t.join(timeout=10)
|
|
67
84
|
|
|
68
85
|
for name, usage in results.items():
|
|
69
86
|
try:
|
package/src/cli.py
CHANGED
package/src/cli_commands.py
CHANGED
|
@@ -7,6 +7,7 @@ from datetime import datetime
|
|
|
7
7
|
|
|
8
8
|
from .claude_refresh import _refresh_claude_sessions
|
|
9
9
|
from .cli_render import _dim, _info, _success, _warn
|
|
10
|
+
from .config import PROVIDER_CODEX
|
|
10
11
|
from .context_store import (
|
|
11
12
|
clear_context,
|
|
12
13
|
edit_context,
|
|
@@ -111,9 +112,16 @@ def _build_handoff_context(source, target, transcript_path, transcript, truncate
|
|
|
111
112
|
])
|
|
112
113
|
|
|
113
114
|
|
|
114
|
-
def _handoff_launch_prompt():
|
|
115
|
+
def _handoff_launch_prompt(session, install=None):
|
|
116
|
+
if session.get("provider") == PROVIDER_CODEX:
|
|
117
|
+
context_ref = "$CODEX_HOME/shared-context.md"
|
|
118
|
+
else:
|
|
119
|
+
context_ref = (install or {}).get("target_path") or os.path.join(
|
|
120
|
+
session.get("authHome") or session.get("sessionRoot") or "~",
|
|
121
|
+
"shared-context.md",
|
|
122
|
+
)
|
|
115
123
|
return (
|
|
116
|
-
"Read
|
|
124
|
+
f"Read {context_ref} first, then resume the previous session "
|
|
117
125
|
"from the latest actionable state. Do not ask me to paste the context again."
|
|
118
126
|
)
|
|
119
127
|
|
|
@@ -124,11 +132,54 @@ def _parse_json_flag(args):
|
|
|
124
132
|
return json_flag, cleaned
|
|
125
133
|
|
|
126
134
|
|
|
135
|
+
def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
|
|
136
|
+
parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
|
|
137
|
+
positionals = []
|
|
138
|
+
index = 0
|
|
139
|
+
while index < len(args):
|
|
140
|
+
arg = args[index]
|
|
141
|
+
if arg in schema:
|
|
142
|
+
spec = schema[arg]
|
|
143
|
+
if spec["type"] == "bool":
|
|
144
|
+
parsed[spec["key"]] = True
|
|
145
|
+
index += 1
|
|
146
|
+
continue
|
|
147
|
+
value, index = _read_option_value(args, index, usage)
|
|
148
|
+
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
149
|
+
continue
|
|
150
|
+
if arg.startswith("--") and "=" in arg:
|
|
151
|
+
flag, value = arg.split("=", 1)
|
|
152
|
+
spec = schema.get(flag)
|
|
153
|
+
if not spec or spec["type"] == "bool":
|
|
154
|
+
raise CdxError(usage)
|
|
155
|
+
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
156
|
+
index += 1
|
|
157
|
+
continue
|
|
158
|
+
if arg.startswith("-"):
|
|
159
|
+
raise CdxError(usage)
|
|
160
|
+
positionals.append(arg)
|
|
161
|
+
if len(positionals) > max_positionals:
|
|
162
|
+
raise CdxError(usage)
|
|
163
|
+
index += 1
|
|
164
|
+
|
|
165
|
+
if positionals_key is not None:
|
|
166
|
+
parsed[positionals_key] = positionals
|
|
167
|
+
return parsed
|
|
168
|
+
|
|
169
|
+
|
|
127
170
|
def _parse_add_args(args):
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
parsed = _parse_flag_args(
|
|
172
|
+
args,
|
|
173
|
+
{},
|
|
174
|
+
"Usage: cdx add [provider] <name> [--json]",
|
|
175
|
+
positionals_key="values",
|
|
176
|
+
max_positionals=2,
|
|
177
|
+
)
|
|
178
|
+
values = parsed["values"]
|
|
179
|
+
if len(values) == 1:
|
|
180
|
+
return {"provider": PROVIDER_CODEX, "name": values[0]}
|
|
181
|
+
if len(values) == 2:
|
|
182
|
+
return {"provider": values[0], "name": values[1]}
|
|
132
183
|
raise CdxError("Usage: cdx add [provider] <name> [--json]")
|
|
133
184
|
|
|
134
185
|
|
|
@@ -145,19 +196,21 @@ def _parse_rename_args(args):
|
|
|
145
196
|
|
|
146
197
|
|
|
147
198
|
def _parse_remove_args(args):
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
199
|
+
parsed = _parse_flag_args(args, {
|
|
200
|
+
"--force": {"key": "force", "type": "bool", "default": False},
|
|
201
|
+
}, "Usage: cdx rmv <name> [--force] [--json]", positionals_key="names", max_positionals=1)
|
|
202
|
+
if len(parsed["names"]) != 1:
|
|
152
203
|
raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
|
|
153
|
-
return {"name": names[0], "force": force}
|
|
204
|
+
return {"name": parsed["names"][0], "force": parsed["force"]}
|
|
154
205
|
|
|
155
206
|
|
|
156
207
|
def _parse_toggle_args(args, usage):
|
|
157
|
-
|
|
158
|
-
|
|
208
|
+
parsed = _parse_flag_args(args, {
|
|
209
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
210
|
+
}, usage, positionals_key="names", max_positionals=1)
|
|
211
|
+
if len(parsed["names"]) != 1:
|
|
159
212
|
raise CdxError(usage)
|
|
160
|
-
return {"name":
|
|
213
|
+
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
161
214
|
|
|
162
215
|
|
|
163
216
|
def _read_option_value(args, index, usage):
|
|
@@ -176,37 +229,12 @@ def _parse_session_names(value):
|
|
|
176
229
|
|
|
177
230
|
|
|
178
231
|
def _parse_update_args(args):
|
|
179
|
-
parsed = {
|
|
180
|
-
"check": False,
|
|
181
|
-
"json": False,
|
|
182
|
-
"yes": False,
|
|
183
|
-
"version": None,
|
|
184
|
-
}
|
|
185
|
-
index = 0
|
|
186
|
-
while index < len(args):
|
|
187
|
-
arg = args[index]
|
|
188
|
-
if arg == "--check":
|
|
189
|
-
parsed["check"] = True
|
|
190
|
-
index += 1
|
|
191
|
-
continue
|
|
192
|
-
if arg == "--json":
|
|
193
|
-
parsed["json"] = True
|
|
194
|
-
index += 1
|
|
195
|
-
continue
|
|
196
|
-
if arg == "--yes":
|
|
197
|
-
parsed["yes"] = True
|
|
198
|
-
index += 1
|
|
199
|
-
continue
|
|
200
|
-
if arg == "--version":
|
|
201
|
-
value, index = _read_option_value(args, index, UPDATE_USAGE)
|
|
202
|
-
parsed["version"] = value
|
|
203
|
-
continue
|
|
204
|
-
if arg.startswith("--version="):
|
|
205
|
-
parsed["version"] = arg.split("=", 1)[1]
|
|
206
|
-
index += 1
|
|
207
|
-
continue
|
|
208
|
-
raise CdxError(UPDATE_USAGE)
|
|
209
|
-
|
|
232
|
+
parsed = _parse_flag_args(args, {
|
|
233
|
+
"--check": {"key": "check", "type": "bool", "default": False},
|
|
234
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
235
|
+
"--yes": {"key": "yes", "type": "bool", "default": False},
|
|
236
|
+
"--version": {"key": "version", "type": "str", "default": None},
|
|
237
|
+
}, UPDATE_USAGE)
|
|
210
238
|
if parsed["check"] and parsed["version"]:
|
|
211
239
|
raise CdxError("Usage: cdx update --check cannot be combined with --version.")
|
|
212
240
|
if parsed["version"] is not None and not parsed["version"].strip():
|
|
@@ -215,52 +243,19 @@ def _parse_update_args(args):
|
|
|
215
243
|
|
|
216
244
|
|
|
217
245
|
def _parse_export_args(args):
|
|
218
|
-
parsed = {
|
|
219
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
index += 1
|
|
232
|
-
continue
|
|
233
|
-
if arg == "--force":
|
|
234
|
-
parsed["force"] = True
|
|
235
|
-
index += 1
|
|
236
|
-
continue
|
|
237
|
-
if arg == "--json":
|
|
238
|
-
parsed["json"] = True
|
|
239
|
-
index += 1
|
|
240
|
-
continue
|
|
241
|
-
if arg == "--sessions":
|
|
242
|
-
value, index = _read_option_value(args, index, EXPORT_USAGE)
|
|
243
|
-
parsed["session_names"] = _parse_session_names(value)
|
|
244
|
-
continue
|
|
245
|
-
if arg.startswith("--sessions="):
|
|
246
|
-
parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
|
|
247
|
-
index += 1
|
|
248
|
-
continue
|
|
249
|
-
if arg == "--passphrase-env":
|
|
250
|
-
value, index = _read_option_value(args, index, EXPORT_USAGE)
|
|
251
|
-
parsed["passphrase_env"] = value
|
|
252
|
-
continue
|
|
253
|
-
if arg.startswith("--passphrase-env="):
|
|
254
|
-
parsed["passphrase_env"] = arg.split("=", 1)[1]
|
|
255
|
-
index += 1
|
|
256
|
-
continue
|
|
257
|
-
if arg.startswith("-"):
|
|
258
|
-
raise CdxError(EXPORT_USAGE)
|
|
259
|
-
if parsed["file_path"] is not None:
|
|
260
|
-
raise CdxError(EXPORT_USAGE)
|
|
261
|
-
parsed["file_path"] = arg
|
|
262
|
-
index += 1
|
|
263
|
-
|
|
246
|
+
parsed = _parse_flag_args(args, {
|
|
247
|
+
"--include-auth": {"key": "include_auth", "type": "bool", "default": False},
|
|
248
|
+
"--force": {"key": "force", "type": "bool", "default": False},
|
|
249
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
250
|
+
"--sessions": {
|
|
251
|
+
"key": "session_names",
|
|
252
|
+
"type": "str",
|
|
253
|
+
"default": None,
|
|
254
|
+
"transform": _parse_session_names,
|
|
255
|
+
},
|
|
256
|
+
"--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
|
|
257
|
+
}, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
|
|
258
|
+
parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
|
|
264
259
|
if not parsed["file_path"]:
|
|
265
260
|
raise CdxError(EXPORT_USAGE)
|
|
266
261
|
if parsed["passphrase_env"] and not parsed["include_auth"]:
|
|
@@ -269,47 +264,18 @@ def _parse_export_args(args):
|
|
|
269
264
|
|
|
270
265
|
|
|
271
266
|
def _parse_import_args(args):
|
|
272
|
-
parsed = {
|
|
273
|
-
"
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
index += 1
|
|
285
|
-
continue
|
|
286
|
-
if arg == "--json":
|
|
287
|
-
parsed["json"] = True
|
|
288
|
-
index += 1
|
|
289
|
-
continue
|
|
290
|
-
if arg == "--sessions":
|
|
291
|
-
value, index = _read_option_value(args, index, IMPORT_USAGE)
|
|
292
|
-
parsed["session_names"] = _parse_session_names(value)
|
|
293
|
-
continue
|
|
294
|
-
if arg.startswith("--sessions="):
|
|
295
|
-
parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
|
|
296
|
-
index += 1
|
|
297
|
-
continue
|
|
298
|
-
if arg == "--passphrase-env":
|
|
299
|
-
value, index = _read_option_value(args, index, IMPORT_USAGE)
|
|
300
|
-
parsed["passphrase_env"] = value
|
|
301
|
-
continue
|
|
302
|
-
if arg.startswith("--passphrase-env="):
|
|
303
|
-
parsed["passphrase_env"] = arg.split("=", 1)[1]
|
|
304
|
-
index += 1
|
|
305
|
-
continue
|
|
306
|
-
if arg.startswith("-"):
|
|
307
|
-
raise CdxError(IMPORT_USAGE)
|
|
308
|
-
if parsed["file_path"] is not None:
|
|
309
|
-
raise CdxError(IMPORT_USAGE)
|
|
310
|
-
parsed["file_path"] = arg
|
|
311
|
-
index += 1
|
|
312
|
-
|
|
267
|
+
parsed = _parse_flag_args(args, {
|
|
268
|
+
"--force": {"key": "force", "type": "bool", "default": False},
|
|
269
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
270
|
+
"--sessions": {
|
|
271
|
+
"key": "session_names",
|
|
272
|
+
"type": "str",
|
|
273
|
+
"default": None,
|
|
274
|
+
"transform": _parse_session_names,
|
|
275
|
+
},
|
|
276
|
+
"--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
|
|
277
|
+
}, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
|
|
278
|
+
parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
|
|
313
279
|
if not parsed["file_path"]:
|
|
314
280
|
raise CdxError(IMPORT_USAGE)
|
|
315
281
|
return parsed
|
|
@@ -551,21 +517,20 @@ def handle_doctor(rest, ctx):
|
|
|
551
517
|
|
|
552
518
|
|
|
553
519
|
def handle_repair(rest, ctx):
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
raise CdxError(REPAIR_USAGE)
|
|
520
|
+
parsed = _parse_flag_args(rest, {
|
|
521
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
522
|
+
"--dry-run": {"key": "dry_run", "type": "bool", "default": False},
|
|
523
|
+
"--force": {"key": "force", "type": "bool", "default": False},
|
|
524
|
+
}, REPAIR_USAGE)
|
|
525
|
+
dry_run = parsed["dry_run"] or not parsed["force"]
|
|
561
526
|
report = repair_health(
|
|
562
527
|
ctx["service"],
|
|
563
528
|
ctx["service"]["base_dir"],
|
|
564
529
|
env=ctx.get("env"),
|
|
565
530
|
dry_run=dry_run,
|
|
566
|
-
force=force,
|
|
531
|
+
force=parsed["force"],
|
|
567
532
|
)
|
|
568
|
-
if
|
|
533
|
+
if parsed["json"]:
|
|
569
534
|
_write_json(ctx, _json_success("repair", "Collected repair report", report=report))
|
|
570
535
|
else:
|
|
571
536
|
ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
|
|
@@ -697,22 +662,23 @@ def handle_context(rest, ctx):
|
|
|
697
662
|
|
|
698
663
|
|
|
699
664
|
def handle_status(rest, ctx):
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
if
|
|
665
|
+
parsed = _parse_flag_args(rest, {
|
|
666
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
667
|
+
"--small": {"key": "small", "type": "bool", "default": False},
|
|
668
|
+
"-s": {"key": "small", "type": "bool", "default": False},
|
|
669
|
+
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
670
|
+
}, STATUS_USAGE, positionals_key="args", max_positionals=1)
|
|
671
|
+
if parsed["json"] and parsed["small"]:
|
|
707
672
|
raise CdxError(STATUS_USAGE)
|
|
708
|
-
|
|
673
|
+
args = parsed["args"]
|
|
674
|
+
if len(args) == 1 and parsed["small"]:
|
|
709
675
|
raise CdxError(STATUS_USAGE)
|
|
710
676
|
|
|
711
677
|
refresh_result = _refresh_claude_sessions(
|
|
712
678
|
ctx["service"],
|
|
713
679
|
ctx.get("refresh_fn"),
|
|
714
680
|
target_names=args if len(args) == 1 else None,
|
|
715
|
-
force=
|
|
681
|
+
force=parsed["refresh"],
|
|
716
682
|
)
|
|
717
683
|
refresh_errors = [
|
|
718
684
|
{
|
|
@@ -732,17 +698,17 @@ def handle_status(rest, ctx):
|
|
|
732
698
|
|
|
733
699
|
rows = ctx["service"]["get_status_rows"]()
|
|
734
700
|
if len(args) == 0:
|
|
735
|
-
if
|
|
701
|
+
if parsed["json"]:
|
|
736
702
|
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
|
|
737
703
|
return 0
|
|
738
|
-
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=
|
|
704
|
+
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
|
|
739
705
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
740
706
|
return 0
|
|
741
707
|
|
|
742
708
|
row = next((r for r in rows if r["session_name"] == args[0]), None)
|
|
743
709
|
if not row:
|
|
744
710
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
745
|
-
if
|
|
711
|
+
if parsed["json"]:
|
|
746
712
|
_write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
|
|
747
713
|
return 0
|
|
748
714
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
@@ -995,7 +961,7 @@ def handle_launch(command, ctx, initial_prompt=None):
|
|
|
995
961
|
if update_notice.get("url"):
|
|
996
962
|
text = f"{text} {update_notice['url']}"
|
|
997
963
|
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
998
|
-
if session["provider"] ==
|
|
964
|
+
if session["provider"] == PROVIDER_CODEX:
|
|
999
965
|
if not json_flag:
|
|
1000
966
|
ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
|
|
1001
967
|
_run_interactive_provider_command(
|
|
@@ -1017,18 +983,19 @@ def handle_handoff(rest, ctx):
|
|
|
1017
983
|
if not session:
|
|
1018
984
|
raise CdxError(f"Unknown session: {name}")
|
|
1019
985
|
install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
|
|
986
|
+
launch_prompt = _handoff_launch_prompt(session, install)
|
|
1020
987
|
if json_flag:
|
|
1021
988
|
_write_json(ctx, _json_success(
|
|
1022
989
|
"handoff",
|
|
1023
990
|
f"Installed shared context for {name}",
|
|
1024
991
|
context=install,
|
|
1025
|
-
launch_prompt=
|
|
992
|
+
launch_prompt=launch_prompt,
|
|
1026
993
|
session=session,
|
|
1027
994
|
))
|
|
1028
995
|
return 0
|
|
1029
996
|
text = f"Shared context installed for {name}: {install['target_path']}"
|
|
1030
997
|
ctx["out"](f"{_info(text, ctx['use_color'])}\n")
|
|
1031
|
-
return handle_launch(name, ctx, initial_prompt=
|
|
998
|
+
return handle_launch(name, ctx, initial_prompt=launch_prompt)
|
|
1032
999
|
|
|
1033
1000
|
source_name, target_name = args
|
|
1034
1001
|
if source_name == target_name:
|
|
@@ -1039,8 +1006,6 @@ def handle_handoff(rest, ctx):
|
|
|
1039
1006
|
target = ctx["service"]["get_session"](target_name)
|
|
1040
1007
|
if not target:
|
|
1041
1008
|
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
1009
|
transcript_path = _latest_launch_transcript_path(source)
|
|
1045
1010
|
if not transcript_path:
|
|
1046
1011
|
raise CdxError(f"No launch transcript found for session: {source_name}")
|
|
@@ -1048,6 +1013,7 @@ def handle_handoff(rest, ctx):
|
|
|
1048
1013
|
context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
|
|
1049
1014
|
write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
|
|
1050
1015
|
install = install_context_for_session(ctx["service"]["base_dir"], target, ctx.get("cwd"))
|
|
1016
|
+
launch_prompt = _handoff_launch_prompt(target, install)
|
|
1051
1017
|
if json_flag:
|
|
1052
1018
|
_write_json(ctx, _json_success(
|
|
1053
1019
|
"handoff",
|
|
@@ -1057,9 +1023,9 @@ def handle_handoff(rest, ctx):
|
|
|
1057
1023
|
target_session=target,
|
|
1058
1024
|
source_transcript=transcript_path,
|
|
1059
1025
|
shared_context=write_result,
|
|
1060
|
-
launch_prompt=
|
|
1026
|
+
launch_prompt=launch_prompt,
|
|
1061
1027
|
))
|
|
1062
1028
|
return 0
|
|
1063
1029
|
text = f"Handoff prepared from {source_name} to {target_name}: {install['target_path']}"
|
|
1064
1030
|
ctx["out"](f"{_info(text, ctx['use_color'])}\n")
|
|
1065
|
-
return handle_launch(target_name, ctx, initial_prompt=
|
|
1031
|
+
return handle_launch(target_name, ctx, initial_prompt=launch_prompt)
|
package/src/codex_usage.py
CHANGED
|
@@ -106,10 +106,10 @@ def _write_json_line(process, payload):
|
|
|
106
106
|
process.stdin.flush()
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def
|
|
109
|
+
def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
|
|
110
110
|
auth_home = session.get("authHome")
|
|
111
111
|
if not auth_home:
|
|
112
|
-
return None
|
|
112
|
+
return {"ok": False, "reason": "missing_auth_home", "status": None}
|
|
113
113
|
|
|
114
114
|
env = os.environ.copy()
|
|
115
115
|
env["CODEX_HOME"] = auth_home
|
|
@@ -139,7 +139,12 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
|
139
139
|
})
|
|
140
140
|
initialized = _read_response(output, 1, timeout)
|
|
141
141
|
if not initialized or initialized.get("error"):
|
|
142
|
-
return
|
|
142
|
+
return {
|
|
143
|
+
"ok": False,
|
|
144
|
+
"reason": "initialize_failed",
|
|
145
|
+
"status": None,
|
|
146
|
+
"response": initialized,
|
|
147
|
+
}
|
|
143
148
|
|
|
144
149
|
_write_json_line(process, {
|
|
145
150
|
"jsonrpc": "2.0",
|
|
@@ -149,13 +154,28 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
|
149
154
|
})
|
|
150
155
|
response = _read_response(output, 2, timeout)
|
|
151
156
|
if not response or response.get("error"):
|
|
152
|
-
return
|
|
157
|
+
return {
|
|
158
|
+
"ok": False,
|
|
159
|
+
"reason": "rate_limits_read_failed",
|
|
160
|
+
"status": None,
|
|
161
|
+
"response": response,
|
|
162
|
+
}
|
|
153
163
|
result = response.get("result") or {}
|
|
154
164
|
by_limit = result.get("rateLimitsByLimitId") or {}
|
|
155
165
|
snapshot = by_limit.get("codex") or result.get("rateLimits")
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
status = normalize_codex_rate_limit_snapshot(snapshot)
|
|
167
|
+
if not status:
|
|
168
|
+
return {
|
|
169
|
+
"ok": False,
|
|
170
|
+
"reason": "missing_rate_limits",
|
|
171
|
+
"status": None,
|
|
172
|
+
"response": response,
|
|
173
|
+
}
|
|
174
|
+
return {"ok": True, "reason": None, "status": status, "response": response}
|
|
175
|
+
except FileNotFoundError as error:
|
|
176
|
+
return {"ok": False, "reason": "codex_cli_not_found", "status": None, "error": str(error)}
|
|
177
|
+
except (OSError, ValueError, BrokenPipeError) as error:
|
|
178
|
+
return {"ok": False, "reason": "probe_failed", "status": None, "error": str(error)}
|
|
159
179
|
finally:
|
|
160
180
|
if process is not None:
|
|
161
181
|
try:
|
|
@@ -166,3 +186,12 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
|
166
186
|
process.kill()
|
|
167
187
|
except OSError:
|
|
168
188
|
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
192
|
+
diagnostic = fetch_codex_rate_limit_diagnostic(
|
|
193
|
+
session,
|
|
194
|
+
timeout=timeout,
|
|
195
|
+
popen_factory=popen_factory,
|
|
196
|
+
)
|
|
197
|
+
return diagnostic.get("status") if diagnostic.get("ok") else None
|
package/src/config.py
CHANGED
package/src/notify.py
CHANGED
|
@@ -29,6 +29,13 @@ def parse_notify_args(args):
|
|
|
29
29
|
raise CdxError("--poll must be a number of seconds") from error
|
|
30
30
|
i += 2
|
|
31
31
|
continue
|
|
32
|
+
if arg.startswith("--poll="):
|
|
33
|
+
try:
|
|
34
|
+
poll = max(1, int(arg.split("=", 1)[1]))
|
|
35
|
+
except ValueError as error:
|
|
36
|
+
raise CdxError("--poll must be a number of seconds") from error
|
|
37
|
+
i += 1
|
|
38
|
+
continue
|
|
32
39
|
if arg.startswith("-"):
|
|
33
40
|
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
|
|
34
41
|
cleaned.append(arg)
|
|
@@ -121,7 +128,24 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
|
121
128
|
_send_windows_notification(title, message, spawn_sync, env)
|
|
122
129
|
elif shutil_which("osascript", env):
|
|
123
130
|
script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
|
|
124
|
-
|
|
131
|
+
_run_notification_command(
|
|
132
|
+
["osascript", "-e", script],
|
|
133
|
+
spawn_sync,
|
|
134
|
+
env,
|
|
135
|
+
)
|
|
136
|
+
elif shutil_which("notify-send", env):
|
|
137
|
+
_run_notification_command(
|
|
138
|
+
["notify-send", str(title), str(message)],
|
|
139
|
+
spawn_sync,
|
|
140
|
+
env,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_notification_command(argv, spawn_sync, env):
|
|
145
|
+
try:
|
|
146
|
+
spawn_sync(argv, env=env, capture_output=True, text=True, timeout=5)
|
|
147
|
+
except (FileNotFoundError, OSError):
|
|
148
|
+
pass
|
|
125
149
|
|
|
126
150
|
|
|
127
151
|
def _send_windows_notification(title, message, spawn_sync, env):
|
package/src/provider_runtime.py
CHANGED
|
@@ -7,6 +7,7 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
|
|
10
|
+
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
|
|
10
11
|
from .errors import CdxError
|
|
11
12
|
|
|
12
13
|
|
|
@@ -99,19 +100,27 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
99
100
|
|
|
100
101
|
|
|
101
102
|
def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
|
|
103
|
+
if initial_prompt is not None:
|
|
104
|
+
if not isinstance(initial_prompt, str):
|
|
105
|
+
raise CdxError("initial_prompt must be a string.")
|
|
106
|
+
if len(initial_prompt) > 32768:
|
|
107
|
+
raise CdxError("initial_prompt exceeds maximum allowed length.")
|
|
102
108
|
cwd = cwd or os.getcwd()
|
|
103
109
|
env_override = env_override or {}
|
|
104
110
|
env = {**os.environ, **env_override}
|
|
105
|
-
if session["provider"] ==
|
|
106
|
-
|
|
111
|
+
if session["provider"] == PROVIDER_CLAUDE:
|
|
112
|
+
args = ["--name", session["name"]]
|
|
113
|
+
if initial_prompt:
|
|
114
|
+
args.append(initial_prompt)
|
|
115
|
+
return _wrap_launch_with_transcript(session, {
|
|
107
116
|
"command": "claude",
|
|
108
|
-
"args":
|
|
117
|
+
"args": args,
|
|
109
118
|
"options": {
|
|
110
119
|
"cwd": cwd,
|
|
111
120
|
"env": {**env, **_home_env_overrides(_get_auth_home(session))},
|
|
112
121
|
},
|
|
113
122
|
"label": "claude",
|
|
114
|
-
}
|
|
123
|
+
}, env=env)
|
|
115
124
|
args = ["--no-alt-screen", "--cd", cwd]
|
|
116
125
|
if initial_prompt:
|
|
117
126
|
args.append(initial_prompt)
|
|
@@ -127,7 +136,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
127
136
|
|
|
128
137
|
def _build_login_status_spec(session, env_override=None):
|
|
129
138
|
env = {**os.environ, **(env_override or {})}
|
|
130
|
-
if session["provider"] ==
|
|
139
|
+
if session["provider"] == PROVIDER_CLAUDE:
|
|
131
140
|
env.update(_home_env_overrides(_get_auth_home(session)))
|
|
132
141
|
|
|
133
142
|
def parser(output):
|
|
@@ -152,7 +161,7 @@ def _build_login_status_spec(session, env_override=None):
|
|
|
152
161
|
def _build_auth_action_spec(session, action, cwd=None, env_override=None):
|
|
153
162
|
cwd = cwd or os.getcwd()
|
|
154
163
|
env = {**os.environ, **(env_override or {})}
|
|
155
|
-
if session["provider"] ==
|
|
164
|
+
if session["provider"] == PROVIDER_CLAUDE:
|
|
156
165
|
env.update(_home_env_overrides(_get_auth_home(session)))
|
|
157
166
|
return {"command": "claude", "args": ["auth", action],
|
|
158
167
|
"options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
|
|
@@ -181,7 +190,7 @@ def _resolve_command(command, env=None):
|
|
|
181
190
|
def _probe_provider_auth(session, spawn_sync=None, env_override=None):
|
|
182
191
|
spawn_sync = spawn_sync or subprocess.run
|
|
183
192
|
spec = _build_login_status_spec(session, env_override)
|
|
184
|
-
if session.get("provider") ==
|
|
193
|
+
if session.get("provider") == PROVIDER_CODEX:
|
|
185
194
|
auth_path = os.path.join(_get_auth_home(session), "auth.json")
|
|
186
195
|
if os.path.isfile(auth_path):
|
|
187
196
|
return True
|
|
@@ -247,11 +256,12 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
247
256
|
spec = _fallback_launch_spec_or_raise(spec, error)
|
|
248
257
|
child = start_child(spec)
|
|
249
258
|
|
|
250
|
-
forwarded_signal =
|
|
259
|
+
forwarded_signal = None
|
|
251
260
|
handlers = []
|
|
252
261
|
|
|
253
262
|
def forward(sig, _frame=None):
|
|
254
|
-
forwarded_signal
|
|
263
|
+
nonlocal forwarded_signal
|
|
264
|
+
forwarded_signal = sig
|
|
255
265
|
try:
|
|
256
266
|
if hasattr(child, "send_signal"):
|
|
257
267
|
child.send_signal(sig)
|
|
@@ -280,7 +290,7 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
280
290
|
|
|
281
291
|
try:
|
|
282
292
|
child.wait()
|
|
283
|
-
if forwarded_signal
|
|
293
|
+
if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
|
|
284
294
|
spec = _fallback_launch_spec_or_raise(spec)
|
|
285
295
|
child = start_child(spec)
|
|
286
296
|
child.wait()
|
|
@@ -298,10 +308,10 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
298
308
|
except (OSError, ValueError):
|
|
299
309
|
pass
|
|
300
310
|
|
|
301
|
-
if forwarded_signal
|
|
311
|
+
if forwarded_signal is not None:
|
|
302
312
|
raise CdxError(
|
|
303
|
-
f"{spec['label']} interrupted by {_signal_name(forwarded_signal
|
|
304
|
-
_signal_exit_code(forwarded_signal
|
|
313
|
+
f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
|
|
314
|
+
_signal_exit_code(forwarded_signal),
|
|
305
315
|
)
|
|
306
316
|
if child.returncode != 0:
|
|
307
317
|
raise CdxError(
|
package/src/session_service.py
CHANGED
|
@@ -8,14 +8,14 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from urllib.parse import quote
|
|
9
9
|
|
|
10
10
|
from .backup_bundle import decode_bundle, encode_bundle
|
|
11
|
-
from .config import get_cdx_home
|
|
11
|
+
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
|
|
12
12
|
from .codex_usage import fetch_codex_rate_limits
|
|
13
13
|
from .errors import CdxError
|
|
14
14
|
from .session_store import create_session_store
|
|
15
15
|
from .status_source import find_latest_status_artifact
|
|
16
16
|
|
|
17
|
-
DEFAULT_PROVIDER =
|
|
18
|
-
ALLOWED_PROVIDERS =
|
|
17
|
+
DEFAULT_PROVIDER = PROVIDER_CODEX
|
|
18
|
+
ALLOWED_PROVIDERS = set(PROVIDERS)
|
|
19
19
|
MAX_SESSION_NAME_LENGTH = 64
|
|
20
20
|
RESERVED_SESSION_NAMES = {
|
|
21
21
|
"add",
|
|
@@ -81,8 +81,15 @@ def _local_now_iso():
|
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
def _safe_relpath(path):
|
|
84
|
-
normalized = str(path or "").replace("\\", "/").
|
|
85
|
-
if
|
|
84
|
+
normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
|
|
85
|
+
if (
|
|
86
|
+
not normalized
|
|
87
|
+
or normalized == "."
|
|
88
|
+
or normalized.startswith("/")
|
|
89
|
+
or (len(normalized) > 1 and normalized[1] == ":")
|
|
90
|
+
or normalized == ".."
|
|
91
|
+
or normalized.startswith("../")
|
|
92
|
+
):
|
|
86
93
|
raise CdxError("Bundle contains an unsafe file path.")
|
|
87
94
|
return normalized
|
|
88
95
|
|
|
@@ -253,7 +260,7 @@ def create_session_service(options=None):
|
|
|
253
260
|
|
|
254
261
|
def _get_session_auth_home(name, provider):
|
|
255
262
|
root = _get_session_root(name)
|
|
256
|
-
if provider ==
|
|
263
|
+
if provider == PROVIDER_CLAUDE:
|
|
257
264
|
return os.path.join(root, "claude-home")
|
|
258
265
|
return root
|
|
259
266
|
|
|
@@ -326,7 +333,7 @@ def create_session_service(options=None):
|
|
|
326
333
|
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
327
334
|
_ensure_private_dir(session_root)
|
|
328
335
|
_ensure_private_dir(auth_home)
|
|
329
|
-
if normalized_provider ==
|
|
336
|
+
if normalized_provider == PROVIDER_CODEX:
|
|
330
337
|
_seed_codex_auth_from_global(auth_home, env=env)
|
|
331
338
|
now = _local_now_iso()
|
|
332
339
|
session = {
|
|
@@ -553,7 +560,7 @@ def create_session_service(options=None):
|
|
|
553
560
|
source_root = session.get("authHome") or _get_session_auth_home(
|
|
554
561
|
session["name"], session["provider"]
|
|
555
562
|
)
|
|
556
|
-
if session["provider"] ==
|
|
563
|
+
if session["provider"] == PROVIDER_CODEX and codex_status_fetcher:
|
|
557
564
|
live_status = codex_status_fetcher({**session, "authHome": source_root})
|
|
558
565
|
if live_status:
|
|
559
566
|
record_status(session["name"], live_status)
|
|
@@ -561,7 +568,7 @@ def create_session_service(options=None):
|
|
|
561
568
|
|
|
562
569
|
expected_account_email = (
|
|
563
570
|
_read_expected_account_email(source_root)
|
|
564
|
-
if session["provider"] ==
|
|
571
|
+
if session["provider"] == PROVIDER_CODEX
|
|
565
572
|
else None
|
|
566
573
|
)
|
|
567
574
|
artifact = find_latest_status_artifact(
|
|
@@ -570,7 +577,7 @@ def create_session_service(options=None):
|
|
|
570
577
|
expected_account_email=expected_account_email,
|
|
571
578
|
)
|
|
572
579
|
if (
|
|
573
|
-
session["provider"] ==
|
|
580
|
+
session["provider"] == PROVIDER_CODEX
|
|
574
581
|
and not artifact
|
|
575
582
|
and os.path.abspath(base_dir) == os.path.abspath(get_cdx_home(env))
|
|
576
583
|
):
|
package/src/status_source.py
CHANGED
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
|
|
6
|
+
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
|
|
6
7
|
|
|
7
8
|
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
|
|
8
9
|
_ANSI_TERMINAL_CONTROL = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
@@ -14,6 +15,23 @@ MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
|
14
15
|
MAX_STATUS_READ_BYTES = 512 * 1024
|
|
15
16
|
MAX_STATUS_CANDIDATE_FILES = 64
|
|
16
17
|
|
|
18
|
+
_KEY_VALUE_PATTERNS = [
|
|
19
|
+
("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
20
|
+
("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
21
|
+
("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
22
|
+
("credits", re.compile(r"credits?\s*[:=]\s*([\d, ]*\d[\d, ]*)\s*(?:credits?)?", re.I)),
|
|
23
|
+
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
24
|
+
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
25
|
+
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
26
|
+
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
27
|
+
("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
28
|
+
("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
29
|
+
("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
30
|
+
("remaining_5h_pct", re.compile(r"remaining\s+5h\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
31
|
+
("remaining_week_pct", re.compile(r"week(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
32
|
+
("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
33
|
+
]
|
|
34
|
+
|
|
17
35
|
|
|
18
36
|
def _strip_ansi(text):
|
|
19
37
|
return _ANSI_ESCAPE.sub("", str(text or ""))
|
|
@@ -185,7 +203,7 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
|
|
|
185
203
|
index = max(index + 1, end_index)
|
|
186
204
|
return blocks
|
|
187
205
|
|
|
188
|
-
if provider !=
|
|
206
|
+
if provider != PROVIDER_CODEX:
|
|
189
207
|
for block in collect_blocks(
|
|
190
208
|
re.compile(r"^\s*(?:[│|]\s*)?Current session\b", re.I),
|
|
191
209
|
[re.compile(p, re.I) for p in [
|
|
@@ -195,7 +213,7 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
|
|
|
195
213
|
):
|
|
196
214
|
items.append({"source_ref": source_ref, "timestamp": timestamp, "text": block})
|
|
197
215
|
|
|
198
|
-
if provider !=
|
|
216
|
+
if provider != PROVIDER_CLAUDE:
|
|
199
217
|
for block in collect_blocks(
|
|
200
218
|
re.compile(r"^\s*(?:[│|]\s*)?5h\s+limit\b", re.I),
|
|
201
219
|
[re.compile(p, re.I) for p in [
|
|
@@ -412,23 +430,7 @@ def extract_named_statuses_from_text(text):
|
|
|
412
430
|
lines = [l.strip() for l in normalized.split("\n") if l.strip()]
|
|
413
431
|
result = {}
|
|
414
432
|
|
|
415
|
-
|
|
416
|
-
("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
417
|
-
("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
418
|
-
("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
419
|
-
("credits", re.compile(r"credits?\s*[:=]\s*([\d, ]*\d[\d, ]*)\s*(?:credits?)?", re.I)),
|
|
420
|
-
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
421
|
-
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
422
|
-
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
423
|
-
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
424
|
-
("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
425
|
-
("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
426
|
-
("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
427
|
-
("remaining_5h_pct", re.compile(r"remaining\s+5h\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
428
|
-
("remaining_week_pct", re.compile(r"week(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
429
|
-
("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
430
|
-
]
|
|
431
|
-
for field, pattern in key_value_patterns:
|
|
433
|
+
for field, pattern in _KEY_VALUE_PATTERNS:
|
|
432
434
|
if field not in result:
|
|
433
435
|
m = pattern.search(normalized)
|
|
434
436
|
if m:
|
|
@@ -620,7 +622,7 @@ def find_latest_status_artifact(root_dir, provider=None, expected_account_email=
|
|
|
620
622
|
|
|
621
623
|
best = None
|
|
622
624
|
for candidate in records:
|
|
623
|
-
if provider ==
|
|
625
|
+
if provider == PROVIDER_CODEX and not _account_matches_expected(
|
|
624
626
|
candidate["text"], expected_account_email
|
|
625
627
|
):
|
|
626
628
|
continue
|