cdx-manager 0.26.5 → 0.26.7

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.7-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
@@ -42,6 +42,41 @@ cdx work
42
42
 
43
43
  CDX checks authentication before interactive launches. Handoff keeps the source transcript in place and asks the receiving agent to verify the conversation and workspace before acting. No account switch or prompt submission happens just because you run `cdx next`.
44
44
 
45
+ ## Quota provenance in status JSON
46
+
47
+ Every profile in `cdx status --json` (including `--cached` and a named profile)
48
+ includes `quota_windows.5h` and `quota_windows.weekly` alongside the existing
49
+ percentage, reset and timestamp fields. Each window contains:
50
+
51
+ - `source`: `codex_rate_limits` for the existing `account/rateLimits/read`
52
+ reader, `claude_probe` for the existing one-token Claude probe,
53
+ `claude_statusline` for explicitly identified Claude statusline evidence,
54
+ `cache` for legacy/local evidence without a known provider origin, or
55
+ `unknown` when the window has no measurement. A cached reading retains its
56
+ known original source; no extra probe is performed for this metadata.
57
+ - `observed_at`: the ISO 8601 measurement time for this profile and window,
58
+ or `null` when absent or unknown. Reading a cache never advances this time.
59
+ - `availability`: `measured` below the freshness threshold, `stale` at or
60
+ beyond it (or when a value has no valid observation time), or `unavailable`
61
+ when no value exists or the current read/authentication attempt failed.
62
+ - `reason`: `null` unless unavailable; otherwise a stable explanation such
63
+ as `missing_window`, `login_required`, `login_expired`, `quota_read_failed`,
64
+ `probe_timeout`, `auth_probe_failed`, `profile_disabled`, or the Codex
65
+ diagnostic reason (`auth_locked`, `initialize_failed`,
66
+ `rate_limits_read_failed`, `missing_rate_limits`, `codex_cli_not_found`,
67
+ `missing_auth_home`, `probe_failed`). Provider error bodies are not copied.
68
+
69
+ Freshness thresholds match the existing cache TTLs: Codex **300 seconds**,
70
+ Claude **600 seconds**, and other providers **60 seconds** for these generic
71
+ window fields. Provider-specific fields such as Antigravity's quota freshness
72
+ retain their existing semantics. Missing windows remain `null`, never an
73
+ assumed 0% or 100%. On a failed refresh, existing cached percentages and reset
74
+ times remain available for compatibility, but the new availability is
75
+ `unavailable` with the failure reason and the original observation time.
76
+ Transient read errors describe the current command; `--cached` evaluates the
77
+ stored evidence and authentication state without making a new read attempt.
78
+ Reset times describe the quota window, not when it was measured.
79
+
45
80
  ## Automatic mission concurrency per profile
46
81
 
47
82
  ```bash
@@ -358,3 +393,41 @@ next_page = read_conversation(
358
393
  cursor=page["next_cursor"], limit=100,
359
394
  )
360
395
  ```
396
+
397
+ ### Claude quota status line
398
+
399
+ cdx installs a cache-only `statusLine` command in each new Claude profile's
400
+ `.claude/settings.json` and updates it on copy, rename, import, interactive launch/resume or `cdx set` /
401
+ `cdx unset`. Existing profiles pick it up at their next interactive launch.
402
+ The line uses the launcher's eight-cell remaining-capacity gauge, for example:
403
+
404
+ ```text
405
+ █▁▁▁▁▁▁▁ 5h 25% · wk 16% (reset Nov 07 09:30) · grif
406
+ ```
407
+
408
+ The gauge follows the lowest known window. Each window below 20% includes its
409
+ cached reset time when available. `stale` marks observations at least ten minutes
410
+ old, missing an observation timestamp, or dated in the future. Missing, unreadable,
411
+ malformed, or unknown quota data produces only `quota ?`, with no error output.
412
+ The status command reads `sessions.json` directly without taking a store lock,
413
+ refreshing authentication, starting a provider, or making a network call. Refresh
414
+ quota separately with `cdx status --refresh` or the tray's normal refresh cycle.
415
+ It targets less than 100 ms per render; Claude's input JSON is not used.
416
+
417
+ The command pins the cdx store path and resolves the profile from the session's
418
+ `HOME` / `USERPROFILE`, with `CDX_SESSION_NAME` as fallback when no home is set.
419
+ It does not follow the launcher's currently selected account. Operator-defined
420
+ `statusLine` values and unrelated settings are preserved; cdx reports a conflict
421
+ in launch/settings output (also in JSON `warnings`). Invalid settings are skipped.
422
+
423
+ ```bash
424
+ cdx set grif --status-line off # Remove only cdx's command and keep it disabled.
425
+ cdx set grif --status-line on # Reinstall without replacing an operator command.
426
+ cdx unset grif --status-line # Return to the default (enabled for Claude).
427
+ cdx set --provider claude --status-line on # Update existing Claude profiles now.
428
+ ```
429
+
430
+ Codex has a built-in status line configured with `tui.status_line`, an ordered list
431
+ of built-in footer item identifiers, rather than Claude's shell-command setting
432
+ ([official configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference)).
433
+ cdx does not change Codex configuration for this feature.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.5
1
+ 0.26.7
@@ -0,0 +1,17 @@
1
+ # CDX Manager 0.26.6
2
+
3
+ Release date: 2026-11-06
4
+
5
+ ## Fixes
6
+
7
+ - Unwrap matching Claude `pasted_content` tags in conversation views so pasted
8
+ text appears without its wrapper (#116,
9
+ `84cb0de57407e4568b8d9918787a8d370ec76d10`). Preserve literal text with
10
+ unmatched tags.
11
+
12
+ ## Maintenance
13
+
14
+ - Record tray companion checksums for v0.26.5
15
+ (`0bd3af61dbb1c848c9a14cbd10ce4b6af439b3c9`).
16
+ - Record release archive checksums for v0.26.5
17
+ (`19d2d3c7443dc5573afce982086fd0d38d89fad2`).
@@ -0,0 +1,20 @@
1
+ # CDX Manager 0.26.7
2
+
3
+ Release date: 2026-11-07
4
+
5
+ ## Improvements
6
+
7
+ - Expose per-profile quota provenance and freshness in status JSON (#118,
8
+ `3a7a30b4c9b0dce099027be1a6874c2b87d574b6`). Add `quota_windows.5h` and
9
+ `quota_windows.weekly` with the source, observation time, availability and
10
+ unavailable reason while preserving existing fields and reset times.
11
+ - Reuse existing quota readers without additional requests. Preserve observation
12
+ times for cached evidence and mark old or undated readings stale rather than
13
+ inventing fresh measurements.
14
+
15
+ ## Maintenance
16
+
17
+ - Record tray companion checksums for v0.26.6
18
+ (`8e006ca40c942115cac99fd998b6f885358f17f3`).
19
+ - Record release archive checksums for v0.26.6
20
+ (`0f0c53d2991ef84f368e73b60cb293a94ce4af4d`).
@@ -1,5 +1,18 @@
1
1
  # Unreleased
2
2
 
3
+ ## Claude profile quota status line
4
+
5
+ - Install a fast cache-only status line for Claude profiles at creation, settings updates and interactive launch/resume. Show the shared launcher gauge, both remaining windows, profile name, low-quota resets and a stale marker; use `quota ?` when data is unavailable.
6
+ - Preserve and report operator-defined status lines. Add `cdx set --status-line on|off` and `cdx unset --status-line` for reversible profile control; leave Codex settings unchanged and document its built-in footer equivalent.
7
+ - Write the standalone status line as UTF-8 when piped, including on Windows, and read repository contract fixtures explicitly as UTF-8.
8
+
9
+
10
+ ## Quota provenance and profile freshness
11
+
12
+ - Add `quota_windows.5h` and `quota_windows.weekly` to status JSON with `source`, per-profile/window ISO `observed_at`, `availability` (`measured`, `stale`, `unavailable`) and a nullable unavailable `reason`. Keep existing fields and reset times compatible.
13
+ - Reuse the existing Codex rate-limit reader and Claude one-token probe without adding requests; distinguish login/read failures and missing windows without inventing percentages. Preserve older window timestamps during backfill.
14
+ - Mark Codex evidence stale at 300 seconds and Claude evidence at 600 seconds (60 seconds for generic other-provider windows); unknown measurement times are stale. Cached evidence retains known provenance and never receives a new observation time.
15
+
3
16
  ## Find and resume interactive sessions
4
17
 
5
18
  - Add exact resolved `cdx history --cwd PATH` and `--interactive` filters before the result limit, preserving the JSON envelope and period/summary support.
@@ -848,6 +848,28 @@
848
848
  "x86_64-pc-windows-msvc": "40e0a2e7d72cacbf2fa5fc241484e901228e44214f053c1c1defede62045624b",
849
849
  "x86_64-unknown-linux-musl": "f24dea41496fa347b64873066b6b57fbbca6480c4bef1769a3bcd47e3dcb6b34"
850
850
  }
851
+ },
852
+ "v0.26.5": {
853
+ "github_tarball_sha256": "45bae3e2dccc49ac47c4c0c3fa57053b391ce3ddf8c56f002f10b56a989d4c1b",
854
+ "github_zip_sha256": "3bdfc7c87beb1a3903e979ec5680d7b18f089cd0ff11728ae895faac472b2759",
855
+ "tray_assets": {
856
+ "aarch64-apple-darwin": "df17b304e0442f5a2f71d18ed754a98d0151b48708117f47a6cb8106055a77b3",
857
+ "aarch64-unknown-linux-musl": "792948a0c48f8221fbf87b6c77e5d4c9e29aac0cf0738857a5ae3346794b5c01",
858
+ "x86_64-apple-darwin": "18923fe1f9bda34ef52b972002845aa914a404beff0de9963f6f9fb0a4c2f0b1",
859
+ "x86_64-pc-windows-msvc": "1ea355b54e05ea6d78a3a23757a9ad5dd2228aa1d939fb7992082e16da579abb",
860
+ "x86_64-unknown-linux-musl": "770fabb26784efb607d2cce6d6bee51c3d7121283c55cf4cacb9b54ac75e4450"
861
+ }
862
+ },
863
+ "v0.26.6": {
864
+ "github_tarball_sha256": "ac54b4e9cb3ab6eb232d31806fcccb9a56720a3d75ee26cb3b81bd9923dd4ca0",
865
+ "github_zip_sha256": "6a195d19ee29ef568d43b6edbf23980b29c9fc35f295269b82e1f32ce5b7b490",
866
+ "tray_assets": {
867
+ "aarch64-apple-darwin": "4d9be82842365447f4d34ddc577b0571e620c0d900da9f751f50337d3362ff68",
868
+ "aarch64-unknown-linux-musl": "0c13305e28348578fd7437e319dc53aecc0f1e4b3ff21db25e19a70b2fdc6e8b",
869
+ "x86_64-apple-darwin": "b7a8d7ea2b3f0e2b8eba08d845eeb7c41e06b8dbfc1915257c920577e23e01d7",
870
+ "x86_64-pc-windows-msvc": "82fcceda389ba33ef60bc01ccce62249c0a1866479611192761778646ad33880",
871
+ "x86_64-unknown-linux-musl": "a1194dc6e672729e7d6ba079b7c1a393e7098e83edd6ab3b6fd63832a7dad136"
872
+ }
851
873
  }
852
874
  }
853
875
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.5",
3
+ "version": "0.26.7",
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.26.5"
7
+ version = "0.26.7"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
@@ -55,6 +55,9 @@ cdx = "cdx_manager.cli:cli_entry"
55
55
  # of which ship the source tree wholesale — worked fine.
56
56
  packages = ["cdx_manager", "cdx_manager.commands"]
57
57
 
58
+ [tool.setuptools.package-data]
59
+ cdx_manager = ["quota_gauge.txt"]
60
+
58
61
  [tool.setuptools.package-dir]
59
62
  cdx_manager = "src"
60
63
  "cdx_manager.commands" = "src/commands"
@@ -2,7 +2,7 @@ import inspect
2
2
  import threading
3
3
  from datetime import datetime, timezone
4
4
 
5
- from .claude_usage import refresh_claude_session_status
5
+ from .claude_usage import ClaudeAuthInvalidError, refresh_claude_session_status
6
6
  from .config import PROVIDER_CLAUDE
7
7
  from .errors import CdxError
8
8
 
@@ -48,6 +48,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
48
48
 
49
49
  errors = []
50
50
  results = {}
51
+ completed = set()
51
52
 
52
53
  threads = []
53
54
 
@@ -61,15 +62,22 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
61
62
  # refresh_fn is the real default, since a caller-injected
62
63
  # refresh_fn (tests, mocks) may not accept auth_refresher.
63
64
  if refresh_fn is refresh_claude_session_status and s["name"] in already_auth_refreshed:
64
- usage = refresh_fn(s, auth_refresher=lambda _auth_home: None)
65
+ usage = refresh_fn(s, auth_refresher=lambda _auth_home: None, diagnostic=True)
66
+ elif refresh_fn is refresh_claude_session_status:
67
+ usage = refresh_fn(s, diagnostic=True)
65
68
  else:
66
69
  usage = refresh_fn(s)
67
70
  if inspect.isawaitable(usage):
68
71
  raise CdxError("Claude refresh function returned an awaitable from a sync callable.")
69
- if usage:
72
+ if usage and usage.get("_quota_error"):
73
+ errors.append({"session": s["name"], "error": CdxError("Claude quota unavailable"), "reason": usage["_quota_error"]})
74
+ elif usage:
70
75
  results[s["name"]] = usage
71
76
  except Exception as e:
72
- errors.append({"session": s["name"], "error": e})
77
+ errors.append({"session": s["name"], "error": e,
78
+ "reason": "login_expired" if isinstance(e, ClaudeAuthInvalidError) else "quota_read_failed"})
79
+ finally:
80
+ completed.add(s["name"])
73
81
 
74
82
  for s in claude_sessions:
75
83
  t = threading.Thread(target=fetch, args=(s,), daemon=True)
@@ -83,6 +91,11 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
83
91
  results = dict(results)
84
92
  errors = list(errors)
85
93
 
94
+ completed = set(completed)
95
+ for session in claude_sessions:
96
+ if session["name"] not in completed:
97
+ errors.append({"session": session["name"], "error": CdxError("Claude quota read timed out"), "reason": "probe_timeout"})
98
+
86
99
  recorded = []
87
100
  for name, usage in results.items():
88
101
  try:
@@ -0,0 +1,139 @@
1
+ """Fast, cache-only Claude status line; executable without importing the CLI."""
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ MARKER = "--cdx-claude-status"
11
+ CACHE_TTL_SECONDS = 10 * 60 # Same freshness window as Claude's cached status.
12
+
13
+
14
+ def _read(path):
15
+ try:
16
+ with open(path, encoding="utf-8") as handle:
17
+ return json.load(handle)
18
+ except (OSError, ValueError):
19
+ return None
20
+
21
+
22
+ def _pct(value):
23
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 100 and math.isfinite(value) else None
24
+
25
+
26
+ def gauge(value):
27
+ # The tray menu embeds this same eight-cell glyph strip at build time.
28
+ full, empty = Path(__file__).with_name("quota_gauge.txt").read_text(encoding="utf-8").splitlines()
29
+ filled = int(math.floor((value or 0) / 100 * len(full) + 0.5))
30
+ return full[:filled] + empty[filled:]
31
+
32
+
33
+ def _clean(value):
34
+ return "".join(c for c in str(value) if c.isprintable())
35
+
36
+
37
+ def render(base_dir, env=None, now=None):
38
+ env = os.environ if env is None else env
39
+ data = _read(os.path.join(base_dir, "sessions.json"))
40
+ if not isinstance(data, dict) or not isinstance(data.get("sessions"), list):
41
+ return "quota ?"
42
+ home = env.get("HOME") or env.get("USERPROFILE")
43
+ name = env.get("CDX_SESSION_NAME")
44
+ matches = [s for s in data["sessions"] if isinstance(s, dict) and s.get("provider") == "claude" and (
45
+ os.path.realpath(s.get("authHome") or "") == os.path.realpath(home) if home else s.get("name") == name
46
+ )]
47
+ if len(matches) != 1:
48
+ return "quota ?"
49
+ session = matches[0]
50
+ status = session.get("lastStatus")
51
+ if not isinstance(status, dict):
52
+ return "quota ?"
53
+ values = [_pct(status.get(f"remaining_{window}_pct")) for window in ("5h", "week")]
54
+ known = [v for v in values if v is not None]
55
+ if not known:
56
+ return "quota ?"
57
+ now = now or datetime.now(timezone.utc)
58
+ evidence = status.get("quota_windows") or {}
59
+ stale = False
60
+ for window, value in zip(("5h", "weekly"), values):
61
+ if value is None:
62
+ continue
63
+ reading = evidence.get(window, {}) if isinstance(evidence, dict) else {}
64
+ if not isinstance(reading, dict):
65
+ reading = {}
66
+ timestamp = reading.get("observed_at", status.get("updated_at") or session.get("lastStatusAt"))
67
+ try:
68
+ observed = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
69
+ stale |= observed.tzinfo is None or not 0 <= (now - observed).total_seconds() < CACHE_TTL_SECONDS
70
+ except (ValueError, TypeError):
71
+ stale = True
72
+ stale |= reading.get("availability") in ("stale", "unavailable")
73
+ parts = []
74
+ for window, label, value in zip(("5h", "week"), ("5h", "wk"), values):
75
+ text = f"{label} {value:g}%" if value is not None else f"{label} ?"
76
+ reset = status.get(f"reset_{window}_at")
77
+ if value is not None and value < 20 and reset:
78
+ text += f" (reset {_clean(reset)})"
79
+ parts.append(text)
80
+ parts.append(_clean(session.get("name", "?")))
81
+ if stale:
82
+ parts.append("stale")
83
+ return f"{gauge(min(known))} " + " · ".join(parts)
84
+
85
+
86
+ def provision(session, base_dir):
87
+ """Return a report on conflicts; never replace an operator's status line."""
88
+ if session.get("provider") != "claude":
89
+ return None
90
+ import shlex
91
+ import subprocess
92
+
93
+ from .agent_notify import _read_json, _write_json
94
+
95
+ path = os.path.join(session["authHome"], ".claude", "settings.json")
96
+ settings = _read_json(path)
97
+ if settings is None:
98
+ return f"{session['name']}: statusLine skipped (unreadable settings.json)"
99
+ current = settings.get("statusLine")
100
+ own = False
101
+ if isinstance(current, dict) and isinstance(current.get("command"), str):
102
+ try:
103
+ parts = shlex.split(current["command"], posix=os.name != "nt")
104
+ own = len(parts) == 4 and os.path.basename(parts[1].strip('"')) == "claude_status_line.py" and parts[2] == MARKER
105
+ except ValueError:
106
+ pass
107
+ if "statusLine" in settings and not own:
108
+ return f"{session['name']}: operator-defined statusLine preserved"
109
+ if (session.get("launch") or {}).get("status_line") is False:
110
+ if not own:
111
+ return None
112
+ del settings["statusLine"]
113
+ else:
114
+ args = [sys.executable, os.path.abspath(__file__), MARKER, os.path.abspath(base_dir)]
115
+ command = subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args)
116
+ desired = {**(current or {}), "type": "command", "command": command}
117
+ if current == desired:
118
+ return None
119
+ settings["statusLine"] = desired
120
+ if not _write_json(path, settings):
121
+ return f"{session['name']}: statusLine skipped (settings.json is not writable)"
122
+ return None
123
+
124
+
125
+ def main(argv=None, stdout=None, env=None):
126
+ argv = sys.argv[1:] if argv is None else argv
127
+ stdout = sys.stdout if stdout is None else stdout
128
+ try:
129
+ text = render(argv[1], env=env) if len(argv) == 2 and argv[0] == MARKER else "quota ?"
130
+ except (OSError, ValueError, TypeError, AttributeError):
131
+ text = "quota ?"
132
+ stdout.write(text + "\n")
133
+ return 0
134
+
135
+
136
+ if __name__ == "__main__":
137
+ # Claude reads UTF-8 from a pipe; Windows otherwise uses its ANSI code page.
138
+ sys.stdout.reconfigure(encoding="utf-8")
139
+ raise SystemExit(main())
@@ -15,6 +15,7 @@ except ImportError: # pragma: no cover - Windows
15
15
 
16
16
  from .claude_credentials import read_keychain_credentials
17
17
  from .errors import CdxError
18
+ from .quota_provenance import quota_windows
18
19
 
19
20
  MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
20
21
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
@@ -368,7 +369,7 @@ def fetch_claude_rate_limit_headers(access_token):
368
369
  reset_week_at = _format_reset_date(_parse_reset_epoch(reset_7d)) if reset_7d else None
369
370
  reset_at = reset_week_at or reset_5h_at
370
371
 
371
- return {
372
+ status = {
372
373
  "remaining_5h_pct": _remaining_from_utilization(utilization_5h),
373
374
  "remaining_week_pct": _remaining_from_utilization(utilization_7d),
374
375
  "reset_5h_at": reset_5h_at,
@@ -378,20 +379,22 @@ def fetch_claude_rate_limit_headers(access_token):
378
379
  "raw_status_text": None,
379
380
  "source_ref": "api:anthropic-ratelimit-headers",
380
381
  }
382
+ status["quota_windows"] = quota_windows(status, "claude")
383
+ return status
381
384
 
382
385
 
383
- def refresh_claude_session_status(session, auth_refresher=None):
386
+ def refresh_claude_session_status(session, auth_refresher=None, diagnostic=False):
384
387
  auth_home = session.get("authHome", "")
385
388
  refresher = auth_refresher or _refresh_claude_cli_credentials
386
389
  refresher(auth_home)
387
390
  creds = _read_claude_credentials(auth_home)
388
391
  if not creds or not creds.get("accessToken"):
389
- return None
392
+ return {"_quota_error": "login_required"} if diagnostic else None
390
393
  if _access_token_expired(creds):
391
394
  # ponytail: `claude auth status` doesn't refresh an expired access
392
395
  # token (verified no-op) and only a real Claude Code invocation does.
393
396
  # Probing anyway just reproduces a 401 we can already predict
394
- # offline, so skip it like the "no credentials" case above and leave
395
- # the cached quota as stale rather than raising a false alert.
396
- return None
397
- return fetch_claude_rate_limit_headers(creds["accessToken"])
397
+ # offline, so skip it and retain the cached observation time.
398
+ return {"_quota_error": "login_expired"} if diagnostic else None
399
+ status = fetch_claude_rate_limit_headers(creds["accessToken"])
400
+ return status or ({"_quota_error": "quota_read_failed"} if diagnostic else None)
package/src/cli.py CHANGED
@@ -266,7 +266,7 @@ def _print_help(use_color=False):
266
266
  f" {_style('cdx configs [--json]', '36', use_color)}",
267
267
  f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
268
268
  f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
269
- f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--max-parallel|--all) [--json]', '36', use_color)}",
269
+ f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--status-line|--notify-preview|--model|--priority|--max-parallel|--all) [--json]', '36', use_color)}",
270
270
  f" {_style('cdx start <name> [--dir PATH] [--resume] [--json]', '36', use_color)}",
271
271
  f" {_style('cdx history backfill-projects [--mapping FILE] [--dry-run|--apply] [--json]', '36', use_color)}",
272
272
  f" {_style('cdx history [name] [--cwd PATH] [--interactive] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
package/src/cli_args.py CHANGED
@@ -35,8 +35,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
35
35
  MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
36
36
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
37
37
  LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
38
- SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--notify on|off] [--agent-state on|off] [--notify-preview on|off] [--scrub-env on|off] [--shared-memory on|off] [--retro on|off] [--policy use|ask-first|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--max-parallel 0..5] [--json]"
39
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--agent-state|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--max-parallel|--all) [--json]"
38
+ SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--notify on|off] [--agent-state on|off] [--status-line on|off] [--notify-preview on|off] [--scrub-env on|off] [--shared-memory on|off] [--retro on|off] [--policy use|ask-first|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--max-parallel 0..5] [--json]"
39
+ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--agent-state|--status-line|--notify-preview|--scrub-env|--shared-memory|--retro|--policy|--model|--fallback-model|--budget|--extra-args|--priority|--max-parallel|--all) [--json]"
40
40
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
41
41
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
42
42
  CONFIGS_USAGE = "Usage: cdx configs [--json]"
@@ -278,6 +278,7 @@ def _parse_set_args(args):
278
278
  "--logics": {"key": "logics", "type": "str", "default": None, "transform": _on_off("logics")},
279
279
  "--notify": {"key": "notify", "type": "str", "default": None, "transform": _on_off("notify")},
280
280
  "--agent-state": {"key": "agent_state", "type": "str", "default": None, "transform": _on_off("agent state")},
281
+ "--status-line": {"key": "status_line", "type": "str", "default": None, "transform": _on_off("status line")},
281
282
  "--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _on_off("notify preview")},
282
283
  "--scrub-env": {"key": "scrub_env", "type": "str", "default": None, "transform": _on_off("scrub env")},
283
284
  "--shared-memory": {"key": "shared_memory", "type": "str", "default": None, "transform": _on_off("shared memory")},
@@ -305,7 +306,7 @@ def _parse_set_args(args):
305
306
  raise CdxError(SET_USAGE)
306
307
  settings = {
307
308
  key: parsed[key]
308
- for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel")
309
+ for key in ("power", "permission", "fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel")
309
310
  if parsed[key] is not None
310
311
  }
311
312
  if not settings:
@@ -329,6 +330,7 @@ def _parse_unset_args(args):
329
330
  "--logics": {"key": "logics", "type": "bool", "default": False},
330
331
  "--notify": {"key": "notify", "type": "bool", "default": False},
331
332
  "--agent-state": {"key": "agent_state", "type": "bool", "default": False},
333
+ "--status-line": {"key": "status_line", "type": "bool", "default": False},
332
334
  "--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
333
335
  "--scrub-env": {"key": "scrub_env", "type": "bool", "default": False},
334
336
  "--shared-memory": {"key": "shared_memory", "type": "bool", "default": False},
@@ -356,7 +358,7 @@ def _parse_unset_args(args):
356
358
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
357
359
  raise CdxError(UNSET_USAGE)
358
360
  _UNSET_KEYS = (
359
- "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro",
361
+ "power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro",
360
362
  "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel",
361
363
  )
362
364
  keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
@@ -313,6 +313,7 @@ def _format_launch_config(session, use_color=False):
313
313
  ("logics", "Logics"),
314
314
  ("notify", "Agent alerts"),
315
315
  ("agent_state", "Agent state"),
316
+ ("status_line", "Claude quota status line"),
316
317
  ("notify_preview", "Response preview"),
317
318
  ("scrub_env", "Scrub agent env"),
318
319
  ("shared_memory", "Shared memory"),
@@ -325,6 +326,8 @@ def _format_launch_config(session, use_color=False):
325
326
  ("policy", "Policy"),
326
327
  ("max_parallel", "Max parallel"),
327
328
  ]:
329
+ if key == "status_line" and session["provider"] != "claude":
330
+ continue
328
331
  rows.append([
329
332
  _dim(label, use_color),
330
333
  _format_launch_setting_value(launch, key, use_color=use_color),
@@ -345,14 +348,16 @@ def _format_launch_settings_hint(name="<name>"):
345
348
  )
346
349
 
347
350
  def _format_launch_setting_value(launch, key, use_color=False):
348
- if key in ("fast", "rtk", "logics", "notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
351
+ if key in ("fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro"):
349
352
  if launch.get(key) is True:
350
353
  return _style("on", "32", use_color)
351
354
  if launch.get(key) is False:
352
355
  return _style("off", "2", use_color)
356
+ if key == "status_line":
357
+ return _dim("on (Claude)", use_color)
353
358
  if key == "logics":
354
359
  return _dim("auto", use_color)
355
- if key in ("notify", "agent_state", "notify_preview", "scrub_env", "shared_memory", "retro"):
360
+ if key in ("notify", "agent_state", "status_line", "notify_preview", "scrub_env", "shared_memory", "retro"):
356
361
  return _style("off", "2", use_color)
357
362
  return _dim("default", use_color)
358
363
  value = launch.get(key)
@@ -7,6 +7,7 @@ import threading
7
7
  import time
8
8
  from datetime import datetime, timezone
9
9
 
10
+ from .quota_provenance import quota_windows
10
11
  from .status_source import _is_zero_credit_balance
11
12
 
12
13
  try:
@@ -139,7 +140,7 @@ def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
139
140
  })
140
141
 
141
142
  raw_status_text = json.dumps(snapshot, sort_keys=True)
142
- return {
143
+ status = {
143
144
  "remaining_5h_pct": _remaining_from_used_percent(
144
145
  five_hour.get("usedPercent", five_hour.get("used_percent"))
145
146
  ),
@@ -156,6 +157,8 @@ def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
156
157
  "raw_status_text": raw_status_text,
157
158
  "source_ref": "api:codex-app-server-rate-limits",
158
159
  }
160
+ status["quota_windows"] = quota_windows(status, "codex")
161
+ return status
159
162
 
160
163
 
161
164
  def _reader_thread(stream, output):
@@ -305,13 +308,18 @@ def _request_codex_app_server(auth_home, method, params, failure_reason, timeout
305
308
  pass
306
309
 
307
310
 
308
- def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
309
- diagnostic = fetch_codex_rate_limit_diagnostic(
311
+ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None, diagnostic=False):
312
+ result = fetch_codex_rate_limit_diagnostic(
310
313
  session,
311
314
  timeout=timeout,
312
315
  popen_factory=popen_factory,
313
316
  )
314
- return diagnostic.get("status") if diagnostic.get("ok") else None
317
+ if result.get("ok"):
318
+ return result.get("status")
319
+ if diagnostic:
320
+ reason = "login_expired" if diagnostic_needs_codex_login(result) else result.get("reason")
321
+ return {"_quota_error": reason or "probe_failed"}
322
+ return None
315
323
 
316
324
 
317
325
  def consume_codex_rate_limit_reset_credit(session, idempotency_key, credit_id=None, timeout=5, popen_factory=None):
@@ -103,12 +103,15 @@ def handle_import(rest, ctx):
103
103
  payload = _json_success(
104
104
  "import",
105
105
  message,
106
+ warnings=result.get("status_line_warnings", []),
106
107
  bundle=result,
107
108
  )
108
109
  if parsed["json"]:
109
110
  _write_json(ctx, payload)
110
111
  return 0
111
112
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
113
+ for warning in result.get("status_line_warnings", []):
114
+ ctx["out"](f"{warning}\n")
112
115
  retained = result.get("retained_local_credentials") or []
113
116
  if retained:
114
117
  # Merge keeps local values, so say which sessions kept their own login
@@ -308,6 +308,13 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
308
308
  if not json_flag:
309
309
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
310
310
  _write_update_notice(ctx)
311
+ from ..claude_status_line import provision as provision_status_line
312
+
313
+ status_line_notice = provision_status_line(session, ctx["service"]["base_dir"])
314
+ if status_line_notice:
315
+ warnings.append(status_line_notice)
316
+ if not json_flag:
317
+ ctx["out"](f"{status_line_notice}\n")
311
318
  alerts_allowed = _notification_provisioning_enabled(session, ctx, json_flag)
312
319
  keep_state = agent_state_enabled(session)
313
320
  installed = provision(
@@ -3,6 +3,7 @@
3
3
  Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
+ from ..claude_status_line import provision as provision_status_line
6
7
  from ..cli_args import (
7
8
  _parse_add_args,
8
9
  _parse_copy_args,
@@ -70,6 +71,7 @@ def handle_copy(rest, ctx):
70
71
  json_flag, args = _parse_json_flag(rest)
71
72
  parsed = _parse_copy_args(args)
72
73
  result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
74
+ warning = provision_status_line(result["session"], ctx["service"]["base_dir"])
73
75
  overwritten = " (overwritten)" if result["overwritten"] else ""
74
76
  message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
75
77
  if json_flag:
@@ -78,20 +80,26 @@ def handle_copy(rest, ctx):
78
80
  message,
79
81
  session=result["session"],
80
82
  overwritten=result["overwritten"],
83
+ warnings=[warning] if warning else [],
81
84
  ))
82
85
  return 0
83
86
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
87
+ if warning:
88
+ ctx["out"](f"{warning}\n")
84
89
  return 0
85
90
 
86
91
  def handle_rename(rest, ctx):
87
92
  json_flag, args = _parse_json_flag(rest)
88
93
  parsed = _parse_rename_args(args)
89
94
  session = ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
95
+ warning = provision_status_line(session, ctx["service"]["base_dir"])
90
96
  message = f"Renamed session {parsed['source']} to {parsed['dest']}"
91
97
  if json_flag:
92
- _write_json(ctx, _json_success("rename", message, session=session))
98
+ _write_json(ctx, _json_success("rename", message, session=session, warnings=[warning] if warning else []))
93
99
  return 0
94
100
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
101
+ if warning:
102
+ ctx["out"](f"{warning}\n")
95
103
  return 0
96
104
 
97
105
  def handle_label(rest, ctx):
@@ -4,6 +4,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
6
  from ..agent_notify import supports_agent_alerts
7
+ from ..claude_status_line import provision as provision_status_line
7
8
  from ..cli_args import _parse_launch_setting_alias_args, _parse_set_args, _parse_unset_args
8
9
  from ..cli_helpers import _format_launch_config, _json_success, _write_json
9
10
  from ..cli_render import _success
@@ -70,6 +71,7 @@ def _apply_launch_settings(parsed, ctx, action="set"):
70
71
  ctx["service"]["set_launch_settings"](session["name"], parsed["settings"])
71
72
  for session in targets
72
73
  ]
74
+ warnings = [notice for session in sessions if (notice := provision_status_line(session, ctx["service"]["base_dir"]))]
73
75
  if len(sessions) == 1:
74
76
  session = sessions[0]
75
77
  message = f"Updated launch settings for {session['name']}"
@@ -80,6 +82,7 @@ def _apply_launch_settings(parsed, ctx, action="set"):
80
82
  action,
81
83
  message,
82
84
  updated_count=len(sessions),
85
+ warnings=warnings,
83
86
  session=sessions[0] if len(sessions) == 1 else None,
84
87
  sessions=sessions,
85
88
  launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
@@ -87,6 +90,8 @@ def _apply_launch_settings(parsed, ctx, action="set"):
87
90
  _write_json(ctx, payload)
88
91
  return 0
89
92
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
93
+ for warning in warnings:
94
+ ctx["out"](f"{warning}\n")
90
95
  notice = _notification_setting_notice(parsed["settings"], sessions, action)
91
96
  if notice:
92
97
  ctx["out"](f"{notice}\n")
@@ -100,6 +105,7 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
100
105
  ctx["service"]["unset_launch_settings"](session["name"], parsed["keys"])
101
106
  for session in targets
102
107
  ]
108
+ warnings = [notice for session in sessions if (notice := provision_status_line(session, ctx["service"]["base_dir"]))]
103
109
  if len(sessions) == 1:
104
110
  session = sessions[0]
105
111
  message = f"Cleared launch settings for {session['name']}"
@@ -110,6 +116,7 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
110
116
  action,
111
117
  message,
112
118
  updated_count=len(sessions),
119
+ warnings=warnings,
113
120
  session=sessions[0] if len(sessions) == 1 else None,
114
121
  sessions=sessions,
115
122
  launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
@@ -117,6 +124,8 @@ def _clear_launch_settings(parsed, ctx, action="unset"):
117
124
  _write_json(ctx, payload)
118
125
  return 0
119
126
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
127
+ for warning in warnings:
128
+ ctx["out"](f"{warning}\n")
120
129
  notice = _notification_setting_notice({key: False for key in parsed["keys"]}, sessions, action)
121
130
  if notice:
122
131
  ctx["out"](f"{notice}\n")
@@ -51,6 +51,7 @@ from ..provider_runtime import (
51
51
  _has_local_claude_auth,
52
52
  _probe_provider_auth_status,
53
53
  )
54
+ from ..quota_provenance import mark_quota_unavailable
54
55
  from ..rtk_savings import rtk_saved
55
56
  from ..run_usage import (
56
57
  TOKEN_USAGE_KEYS,
@@ -856,6 +857,8 @@ def handle_status(rest, ctx):
856
857
  }
857
858
  for item in refresh_result.get("errors", [])
858
859
  ]
860
+ quota_errors = {item["session"]: item.get("reason") or "quota_read_failed" for item in refresh_result.get("errors", [])}
861
+ quota_errors.update({item["session"]: "auth_probe_failed" for item in auth_refresh.get("errors", [])})
859
862
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
860
863
  if len(args) == 1:
861
864
  row = ctx["service"]["get_status_row"](
@@ -865,6 +868,8 @@ def handle_status(rest, ctx):
865
868
  cache_only=parsed["cached"],
866
869
  status_timeout_seconds=parsed["timeout"],
867
870
  )
871
+ if row.get("session_name") in quota_errors:
872
+ mark_quota_unavailable(row, quota_errors[row["session_name"]])
868
873
  output_warnings = [
869
874
  *warnings,
870
875
  *_refresh_warning_payloads(refresh_errors, [row]),
@@ -885,6 +890,9 @@ def handle_status(rest, ctx):
885
890
  cache_only=parsed["cached"],
886
891
  status_timeout_seconds=parsed["timeout"],
887
892
  )
893
+ for row in rows:
894
+ if row.get("session_name") in quota_errors:
895
+ mark_quota_unavailable(row, quota_errors[row["session_name"]])
888
896
  output_warnings = [
889
897
  *warnings,
890
898
  *_refresh_warning_payloads(refresh_errors, rows),
@@ -66,6 +66,8 @@ _CLAUDE_HARNESS = re.compile(
66
66
  r"\s*(?:<(?P<tag>task-notification|local-command-stdout|local-command-stderr|command-name|command-message|command-args|system-reminder)>"
67
67
  r"(?:(?!</(?P=tag)>).)*</(?P=tag)>\s*)+", re.S,
68
68
  )
69
+ # Claude Code wraps long pasted text in matching tags; show only the pasted text.
70
+ _CLAUDE_PASTED = re.compile(r'<pasted_content id="([0-9A-Za-z]{1,32})">\n?(.*?)\n?</pasted_content id="\1">', re.S)
69
71
  _CLAUDE_MID_TURN_START = "The user sent a new message while you were working:\n"
70
72
  _CLAUDE_MID_TURN_END = re.compile(
71
73
  r"\n+This is how Claude Code surfaces messages the user sends mid-turn[^\n]*"
@@ -81,6 +83,8 @@ def _claude_user_content(content):
81
83
  blocks = [dict(block) if isinstance(block, dict) else block for block in blocks]
82
84
  texts = [block for block in blocks if isinstance(block, dict)
83
85
  and block.get("type") == "text" and isinstance(block.get("text"), str)]
86
+ for block in texts:
87
+ block["text"] = _CLAUDE_PASTED.sub(r"\2", block["text"])
84
88
  # Require both anchored boundaries; a quoted sentence alone is user content.
85
89
  if texts and texts[0]["text"].startswith(_CLAUDE_MID_TURN_START):
86
90
  end = _CLAUDE_MID_TURN_END.search(texts[-1]["text"])
@@ -0,0 +1,2 @@
1
+ ████████
2
+ ▁▁▁▁▁▁▁▁
@@ -0,0 +1,45 @@
1
+ """Additive quota evidence; never infer a measurement from a missing value."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ SOURCES = {"codex_rate_limits", "claude_probe", "claude_statusline", "cache", "unknown"}
6
+
7
+
8
+ def quota_windows(status, provider, reason=None, now=None):
9
+ status = status or {}
10
+ now = now or datetime.now(timezone.utc)
11
+ ttl = {"codex": 300, "claude": 600}.get(provider, 60)
12
+ source = {
13
+ "api:codex-app-server-rate-limits": "codex_rate_limits",
14
+ "api:anthropic-ratelimit-headers": "claude_probe",
15
+ "claude:statusline": "claude_statusline",
16
+ }.get(status.get("source_ref"), "cache")
17
+ windows = {}
18
+ for name, field in (("5h", "remaining_5h_pct"), ("weekly", "remaining_week_pct")):
19
+ evidence = (status.get("quota_windows") or {}).get(name, {})
20
+ value = status.get(field)
21
+ observed_at = evidence.get("observed_at", status.get("updated_at")) if value is not None else None
22
+ try:
23
+ observed = datetime.fromisoformat(str(observed_at).replace("Z", "+00:00"))
24
+ if observed.tzinfo is None:
25
+ observed = observed.replace(tzinfo=timezone.utc)
26
+ observed_at = observed.isoformat()
27
+ except (TypeError, ValueError):
28
+ observed, observed_at = None, None
29
+ window_reason = reason or ("missing_window" if value is None else None)
30
+ availability = "unavailable" if window_reason else "measured"
31
+ if not window_reason and (observed is None or (now - observed).total_seconds() >= ttl):
32
+ availability = "stale"
33
+ window_source = evidence.get("source", source) if value is not None else "unknown"
34
+ windows[name] = {
35
+ "source": window_source if window_source in SOURCES else "unknown",
36
+ "observed_at": observed_at,
37
+ "availability": availability,
38
+ "reason": window_reason,
39
+ }
40
+ return windows
41
+
42
+
43
+ def mark_quota_unavailable(row, reason):
44
+ for window in row.get("quota_windows", {}).values():
45
+ window.update(availability="unavailable", reason=reason)
@@ -441,7 +441,11 @@ def import_bundle(base_dir, store, file_path,
441
441
  if recovery_dir:
442
442
  remove_tree(recovery_dir, ignore_errors=True)
443
443
 
444
+ from .claude_status_line import provision
445
+
446
+ status_line_warnings = [notice for name in names if (notice := provision(store["get_session"](name), base_dir))]
444
447
  return {
448
+ "status_line_warnings": status_line_warnings,
445
449
  "path": file_path,
446
450
  "session_names": names,
447
451
  "include_auth": bool(decoded["meta"].get("include_auth")),
@@ -149,7 +149,7 @@ def _seed_codex_auth_from_global(auth_home, env=None):
149
149
  return True
150
150
 
151
151
 
152
- _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "agent_state": "agent state", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
152
+ _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "agent_state": "agent state", "status_line": "status line", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
153
153
 
154
154
 
155
155
  def _has_control_chars(text):
@@ -284,7 +284,7 @@ def _normalize_session_label(label):
284
284
  return text
285
285
 
286
286
 
287
- def set_launch_settings(store, name, settings):
287
+ def set_launch_settings(store, name, settings, base_dir=None):
288
288
  session = store["get_session"](name)
289
289
  if not session:
290
290
  raise CdxError(f"Unknown session: {name}")
@@ -317,16 +317,21 @@ def set_launch_settings(store, name, settings):
317
317
  "updatedAt": now,
318
318
  }
319
319
 
320
- return store["update_session"](name, updater)
320
+ updated = store["update_session"](name, updater)
321
+ if base_dir is not None:
322
+ from .claude_status_line import provision
323
+
324
+ provision(updated, base_dir)
325
+ return updated
321
326
 
322
- def unset_launch_settings(store, name, keys):
327
+ def unset_launch_settings(store, name, keys, base_dir=None):
323
328
  session = store["get_session"](name)
324
329
  if not session:
325
330
  raise CdxError(f"Unknown session: {name}")
326
331
  if not keys:
327
332
  raise CdxError("At least one launch setting is required.")
328
333
  allowed = {
329
- "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "notify_preview",
334
+ "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "agent_state", "status_line", "notify_preview",
330
335
  "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy", "max_parallel",
331
336
  }
332
337
  unknown = [key for key in keys if key not in allowed]
@@ -345,7 +350,12 @@ def unset_launch_settings(store, name, keys):
345
350
  updated.pop("launch", None)
346
351
  return updated
347
352
 
348
- return store["update_session"](name, updater)
353
+ updated = store["update_session"](name, updater)
354
+ if base_dir is not None:
355
+ from .claude_status_line import provision
356
+
357
+ provision(updated, base_dir)
358
+ return updated
349
359
 
350
360
  def update_auth_state(store, name, updater):
351
361
  now = _local_now_iso()
@@ -555,6 +565,9 @@ def copy_session(base_dir, store, source_name, dest_name):
555
565
  remove_tree(backup_root, ignore_errors=True)
556
566
  finally:
557
567
  remove_tree(temp_parent, ignore_errors=True)
568
+ from .claude_status_line import provision
569
+
570
+ provision(result["session"], base_dir)
558
571
  return {"session": result["session"], "overwritten": overwritten}
559
572
 
560
573
 
@@ -604,6 +617,9 @@ def rename_session(base_dir, store, source_name, dest_name):
604
617
  delete_keychain_credentials(source_auth_home)
605
618
  except CdxError:
606
619
  raise CdxError(f"Renamed session to {dest_name}, but old keychain credential cleanup failed; the new session is usable and the old entry was retained.") from None
620
+ from .claude_status_line import provision
621
+
622
+ provision(result["session"], base_dir)
607
623
  return result["session"]
608
624
 
609
625
 
@@ -647,6 +663,9 @@ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
647
663
  _seed_codex_auth_from_global(auth_home, env=env)
648
664
  if normalized_provider == PROVIDER_CLAUDE:
649
665
  _ensure_claude_attribution_disabled(auth_home)
666
+ from .claude_status_line import provision
667
+
668
+ provision({"provider": normalized_provider, "authHome": auth_home, "name": name}, base_dir)
650
669
  if normalized_provider == "antigravity":
651
670
  from .antigravity_status import install
652
671
 
@@ -1034,8 +1053,8 @@ def create_session_service(options=None):
1034
1053
  "set_session_enabled": partial(set_session_enabled, store),
1035
1054
  "set_session_label": partial(set_session_label, store),
1036
1055
  "clear_session_label": partial(clear_session_label, store),
1037
- "set_launch_settings": partial(set_launch_settings, store),
1038
- "unset_launch_settings": partial(unset_launch_settings, store),
1056
+ "set_launch_settings": partial(set_launch_settings, store, base_dir=base_dir),
1057
+ "unset_launch_settings": partial(unset_launch_settings, store, base_dir=base_dir),
1039
1058
  "record_status": partial(record_status, store),
1040
1059
  "record_launch_history": partial(record_launch_history, store),
1041
1060
  "get_launch_history": partial(get_launch_history, store),
@@ -13,6 +13,7 @@ from .claude_credentials import claude_auth_type, setup_token_days_left
13
13
  from .claude_usage import _decode_jwt_claims
14
14
  from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, get_cdx_home
15
15
  from .errors import CdxError
16
+ from .quota_provenance import quota_windows
16
17
  from .session_helpers import (
17
18
  _get_global_codex_home,
18
19
  _get_session_auth_home,
@@ -158,6 +159,7 @@ def _normalize_status_payload(payload=None):
158
159
  "raw_status_text": payload.get("raw_status_text"),
159
160
  "source_ref": payload.get("source_ref"),
160
161
  "structured": bool(payload.get("structured")),
162
+ "quota_windows": payload.get("quota_windows"),
161
163
  }
162
164
 
163
165
  def _parse_status_timestamp(value):
@@ -240,6 +242,11 @@ def _merge_status_payload(current, candidate):
240
242
  return current
241
243
 
242
244
  merged = dict(current)
245
+ merged["quota_windows"] = dict(current.get("quota_windows") or quota_windows(current, "unknown"))
246
+ candidate_windows = candidate.get("quota_windows") or quota_windows(candidate, "unknown")
247
+ for name, field in (("5h", "remaining_5h_pct"), ("weekly", "remaining_week_pct")):
248
+ if current.get(field) is None and candidate.get(field) is not None:
249
+ merged["quota_windows"][name] = candidate_windows[name]
243
250
  for field in [
244
251
  "usage_pct",
245
252
  "remaining_5h_pct",
@@ -336,7 +343,7 @@ def _fetch_codex_status(codex_status_fetcher, custom_codex_status_fetcher, defau
336
343
  if custom_codex_status_fetcher:
337
344
  return custom_codex_status_fetcher(session)
338
345
  timeout = timeout_seconds or default_status_timeout_seconds
339
- return codex_status_fetcher(session, timeout=timeout)
346
+ return codex_status_fetcher(session, timeout=timeout, diagnostic=True)
340
347
 
341
348
  def session_paths(session):
342
349
  """Where a session's files live, so a caller never rebuilds cdx's layout.
@@ -415,6 +422,12 @@ def _status_row_from_session(store, s):
415
422
  )})
416
423
  if enabled and antigravity["quota_stale"]:
417
424
  row["status"] = "stale"
425
+ reason = s.get("_quota_error")
426
+ if not enabled:
427
+ reason = "profile_disabled"
428
+ elif row["auth_status"] == "logged_out":
429
+ reason = "login_required"
430
+ row["quota_windows"] = quota_windows(row_status, s["provider"], reason=reason)
418
431
  row["runs_out_at_iso"] = _runs_out(row)
419
432
  return row
420
433
 
@@ -465,9 +478,13 @@ def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
465
478
  {**session, "authHome": source_root},
466
479
  timeout_seconds=status_timeout_seconds,
467
480
  )
468
- if live_status:
481
+ if live_status and live_status.get("_quota_error"):
482
+ session["_quota_error"] = live_status["_quota_error"]
483
+ elif live_status:
469
484
  record_status(store, session["name"], live_status)
470
485
  return live_status
486
+ else:
487
+ session["_quota_error"] = "quota_read_failed"
471
488
 
472
489
  expected_account_email = (
473
490
  _read_expected_account_email(source_root)
@@ -494,6 +511,12 @@ def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
494
511
  # structured payloads there cannot be attributed to ours.
495
512
  trust_unattributed_structured=False,
496
513
  )
514
+ if artifact and (artifact.get("structured") or not expected_account_email):
515
+ # Shared artifacts without verified identity cannot date this profile.
516
+ artifact["quota_windows"] = {
517
+ name: {"source": "cache", "observed_at": None}
518
+ for name in ("5h", "weekly")
519
+ }
497
520
  if not artifact:
498
521
  if _is_low_confidence_status_source(current_status):
499
522
  return None
@@ -511,6 +534,7 @@ def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
511
534
  "raw_status_text": artifact.get("raw_status_text"),
512
535
  "source_ref": artifact.get("source_ref"),
513
536
  "structured": artifact.get("structured"),
537
+ "quota_windows": artifact.get("quota_windows"),
514
538
  })
515
539
  if _is_low_confidence_status_source(current_status) and not _is_low_confidence_status_source(resolved):
516
540
  record_status(store, session["name"], resolved)
@@ -4,6 +4,7 @@ import re
4
4
  from datetime import datetime, timezone
5
5
 
6
6
  from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
7
+ from .quota_provenance import quota_windows
7
8
 
8
9
  _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
9
10
  _ANSI_TERMINAL_CONTROL = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
@@ -513,11 +514,6 @@ def extract_named_statuses_from_text(text):
513
514
  if "remaining_week_pct" not in result and week_used is not None:
514
515
  result["remaining_week_pct"] = max(0, 100 - week_used)
515
516
 
516
- if re.search(r"No stats available yet\.?\s+Start using Claude Code", normalized, re.I):
517
- result.setdefault("usage_pct", 0)
518
- result.setdefault("remaining_5h_pct", 100)
519
- result.setdefault("remaining_week_pct", 100)
520
-
521
517
  # Table header row
522
518
  header_idx = next(
523
519
  (i for i, l in enumerate(lines)
@@ -722,6 +718,9 @@ def _backfill_missing_fields(target, source):
722
718
  continue
723
719
  if target.get(field) is None and source.get(field) is not None:
724
720
  target[field] = source[field]
721
+ name = {"remaining_5h_pct": "5h", "remaining_week_pct": "weekly"}.get(field)
722
+ if name:
723
+ target.setdefault("quota_windows", {})[name] = (source.get("quota_windows") or quota_windows(source, "unknown"))[name]
725
724
 
726
725
 
727
726
  def _record_timestamp_epoch(value):
@@ -792,9 +791,9 @@ def find_latest_status_artifact(
792
791
  score = stat.st_mtime
793
792
  else:
794
793
  score = 0
795
- # Each record producer stamps "trusted" where it knows the source kind.
796
- # Untrusted records are text scraped from jsonl payloads: they can be
797
- # conversational noise quoting an old status block.
794
+ parsed["updated_at"] = datetime.fromtimestamp(score, tz=timezone.utc).isoformat() if score else None
795
+ parsed["quota_windows"] = quota_windows(parsed, provider)
796
+ # Trust comes from the record producer, not conversational text.
798
797
  trusted = bool(candidate.get("trusted"))
799
798
  # Structured payloads carry no account identity, so the email guard
800
799
  # above cannot filter them. In roots shared by several accounts the