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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -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.2 sh install.sh
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.5.2"
7
+ version = "0.5.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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"] == "claude"
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
- def fetch(s):
51
- try:
52
- usage = refresh_fn(s)
53
- if inspect.isawaitable(usage):
54
- import asyncio
55
- usage = asyncio.run(usage)
56
- if usage:
57
- results[s["name"]] = usage
58
- except Exception as e:
59
- errors.append({"session": s["name"], "error": e})
60
-
61
- for s in claude_sessions:
62
- t = threading.Thread(target=fetch, args=(s,))
63
- threads.append(t)
64
- t.start()
65
- for t in threads:
66
- t.join()
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
@@ -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.2"
52
+ VERSION = "0.5.4"
53
53
 
54
54
 
55
55
  # ---------------------------------------------------------------------------
@@ -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 $CODEX_HOME/shared-context.md first, then resume the previous session "
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
- if len(args) == 1:
129
- return {"provider": "codex", "name": args[0]}
130
- if len(args) == 2:
131
- return {"provider": args[0], "name": args[1]}
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
- force = "--force" in args
149
- names = [a for a in args if a != "--force"]
150
- unknown = [a for a in args if a.startswith("-") and a != "--force"]
151
- if unknown or len(names) != 1 or len(args) > 2:
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
- json_flag, cleaned = _parse_json_flag(args)
158
- if len(cleaned) != 1:
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": cleaned[0], "json": json_flag}
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
- "file_path": None,
220
- "include_auth": False,
221
- "force": False,
222
- "json": False,
223
- "session_names": None,
224
- "passphrase_env": None,
225
- }
226
- index = 0
227
- while index < len(args):
228
- arg = args[index]
229
- if arg == "--include-auth":
230
- parsed["include_auth"] = True
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
- "file_path": None,
274
- "force": False,
275
- "json": False,
276
- "session_names": None,
277
- "passphrase_env": None,
278
- }
279
- index = 0
280
- while index < len(args):
281
- arg = args[index]
282
- if arg == "--force":
283
- parsed["force"] = True
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
- json_flag = "--json" in rest
555
- dry_run = "--dry-run" in rest or "--force" not in rest
556
- force = "--force" in rest
557
- allowed = {"--json", "--dry-run", "--force"}
558
- unknown = [arg for arg in rest if arg not in allowed]
559
- if unknown:
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 json_flag:
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
- json_flag = "--json" in rest
701
- small_flag = "--small" in rest or "-s" in rest
702
- refresh_flag = "--refresh" in rest
703
- status_flags = {"--json", "--small", "-s", "--refresh"}
704
- args = [a for a in rest if a not in status_flags]
705
- unknown_flags = [a for a in args if a.startswith("-")]
706
- if unknown_flags or (json_flag and small_flag):
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
- if len(args) > 1 or (len(args) == 1 and small_flag):
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=refresh_flag,
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 json_flag:
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=small_flag)}\n")
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 json_flag:
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"] == "codex":
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=_handoff_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=_handoff_launch_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=_handoff_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=_handoff_launch_prompt())
1031
+ return handle_launch(target_name, ctx, initial_prompt=launch_prompt)
@@ -106,10 +106,10 @@ def _write_json_line(process, payload):
106
106
  process.stdin.flush()
107
107
 
108
108
 
109
- def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
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 None
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 None
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
- return normalize_codex_rate_limit_snapshot(snapshot)
157
- except (OSError, ValueError, BrokenPipeError):
158
- return None
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
@@ -1,6 +1,10 @@
1
1
  import os
2
2
  from pathlib import Path
3
3
 
4
+ PROVIDER_CODEX = "codex"
5
+ PROVIDER_CLAUDE = "claude"
6
+ PROVIDERS = (PROVIDER_CODEX, PROVIDER_CLAUDE)
7
+
4
8
 
5
9
  def get_cdx_home(env=None):
6
10
  if env is None:
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
- spawn_sync(["osascript", "-e", script], env=env, capture_output=True, text=True)
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):
@@ -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"] == "claude":
106
- return {
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": ["--name", session["name"]],
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"] == "claude":
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"] == "claude":
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") == "codex":
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 = [None]
259
+ forwarded_signal = None
251
260
  handlers = []
252
261
 
253
262
  def forward(sig, _frame=None):
254
- forwarded_signal[0] = sig
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[0] is None and child.returncode != 0 and _should_retry_without_transcript(spec):
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[0] is not None:
311
+ if forwarded_signal is not None:
302
312
  raise CdxError(
303
- f"{spec['label']} interrupted by {_signal_name(forwarded_signal[0])} for session {session['name']}",
304
- _signal_exit_code(forwarded_signal[0]),
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(
@@ -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 = "codex"
18
- ALLOWED_PROVIDERS = {"codex", "claude"}
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("\\", "/").strip("/")
85
- if not normalized or normalized.startswith("../") or "/../" in f"/{normalized}/":
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 == "claude":
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 == "codex":
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"] == "codex" and codex_status_fetcher:
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"] == "codex"
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"] == "codex"
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
  ):
@@ -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 != "codex":
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 != "claude":
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
- key_value_patterns = [
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 == "codex" and not _account_matches_expected(
625
+ if provider == PROVIDER_CODEX and not _account_matches_expected(
624
626
  candidate["text"], expected_account_email
625
627
  ):
626
628
  continue