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 +74 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_6.md +17 -0
- package/changelogs/CHANGELOGS_0_26_7.md +20 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +13 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +4 -1
- package/src/claude_refresh.py +17 -4
- package/src/claude_status_line.py +139 -0
- package/src/claude_usage.py +10 -7
- package/src/cli.py +1 -1
- package/src/cli_args.py +6 -4
- package/src/cli_helpers.py +7 -2
- package/src/codex_usage.py +12 -4
- package/src/commands/backup.py +3 -0
- package/src/commands/launch.py +7 -0
- package/src/commands/lifecycle.py +9 -1
- package/src/commands/settings.py +9 -0
- package/src/commands/status.py +8 -0
- package/src/conversation.py +4 -0
- package/src/quota_gauge.txt +2 -0
- package/src/quota_provenance.py +45 -0
- package/src/session_backup.py +4 -0
- package/src/session_service.py +27 -8
- package/src/session_status.py +26 -2
- package/src/status_source.py +7 -8
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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
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.
|
|
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"
|
package/src/claude_refresh.py
CHANGED
|
@@ -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())
|
package/src/claude_usage.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return
|
|
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]]
|
package/src/cli_helpers.py
CHANGED
|
@@ -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)
|
package/src/codex_usage.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
package/src/commands/backup.py
CHANGED
|
@@ -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
|
package/src/commands/launch.py
CHANGED
|
@@ -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):
|
package/src/commands/settings.py
CHANGED
|
@@ -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")
|
package/src/commands/status.py
CHANGED
|
@@ -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),
|
package/src/conversation.py
CHANGED
|
@@ -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,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)
|
package/src/session_backup.py
CHANGED
|
@@ -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")),
|
package/src/session_service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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),
|
package/src/session_status.py
CHANGED
|
@@ -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)
|
package/src/status_source.py
CHANGED
|
@@ -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
|
-
|
|
796
|
-
|
|
797
|
-
#
|
|
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
|