cdx-manager 0.26.6 → 0.26.8

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.6-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.8-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
@@ -85,6 +120,14 @@ cdx run work --cwd /path/to/repo --prompt "Implement the change" --reasoning-eff
85
120
  cdx run --continue-run RUN_ID --cwd /path/to/repo --prompt "Fix the remaining test failure" --json
86
121
  ```
87
122
 
123
+ Pass provider arguments for a single run with `--extra-args 'ARGS'`. They use the session setting's POSIX quoting and validation (nonempty, at most 512 characters, no control characters, balanced quotes), and are appended after the profile's `extra_args`. They do not change the profile or subsequent interactive launches. Provider flag semantics remain unvalidated, as listed under `unvalidated.extra_args` in `cdx schema --json`; existing provider permission and workspace isolation handling still applies. Codex headless launches retain their existing behavior of ignoring session and run extra arguments.
124
+
125
+ ```bash
126
+ cdx run claude-work --cwd /path/to/repo --permission default --prompt 'Run: touch t' --extra-args '--allowedTools "Bash(touch t)" --strict-mcp-config' --json
127
+ ```
128
+
129
+ This allows the requested Claude tool and restricts MCP configuration for this run only. The option also follows detached runs and profile failover. With `--continue-run`, Claude extra arguments that override model, effort or conversation are refused, whether supplied by the session or this run.
130
+
88
131
  `--continue-run` resumes the exact Claude session or Codex thread and pins the previous run's recorded model and effort, even if the profile settings changed. The profile is inferred from the parent; an explicit session or provider must match. An explicit model or effort must also match. Runs and history record `parent_run_id` and `conversation_id`. Detached continuations use the cdx child supervisor with the same pins. Codex continuation usage is measured between exact transcript snapshots before and after the run, since exec resume can report conversation totals. If either boundary is unavailable, per-run usage stays unknown with `usage_attribution: "continuation_usage_unavailable"` in history instead of counting earlier turns again.
89
132
 
90
133
  Headless runs, including detached, remote and continued runs, keep their
@@ -358,3 +401,41 @@ next_page = read_conversation(
358
401
  cursor=page["next_cursor"], limit=100,
359
402
  )
360
403
  ```
404
+
405
+ ### Claude quota status line
406
+
407
+ cdx installs a cache-only `statusLine` command in each new Claude profile's
408
+ `.claude/settings.json` and updates it on copy, rename, import, interactive launch/resume or `cdx set` /
409
+ `cdx unset`. Existing profiles pick it up at their next interactive launch.
410
+ The line uses the launcher's eight-cell remaining-capacity gauge, for example:
411
+
412
+ ```text
413
+ █▁▁▁▁▁▁▁ 5h 25% · wk 16% (reset Nov 07 09:30) · grif
414
+ ```
415
+
416
+ The gauge follows the lowest known window. Each window below 20% includes its
417
+ cached reset time when available. `stale` marks observations at least ten minutes
418
+ old, missing an observation timestamp, or dated in the future. Missing, unreadable,
419
+ malformed, or unknown quota data produces only `quota ?`, with no error output.
420
+ The status command reads `sessions.json` directly without taking a store lock,
421
+ refreshing authentication, starting a provider, or making a network call. Refresh
422
+ quota separately with `cdx status --refresh` or the tray's normal refresh cycle.
423
+ It targets less than 100 ms per render; Claude's input JSON is not used.
424
+
425
+ The command pins the cdx store path and resolves the profile from the session's
426
+ `HOME` / `USERPROFILE`, with `CDX_SESSION_NAME` as fallback when no home is set.
427
+ It does not follow the launcher's currently selected account. Operator-defined
428
+ `statusLine` values and unrelated settings are preserved; cdx reports a conflict
429
+ in launch/settings output (also in JSON `warnings`). Invalid settings are skipped.
430
+
431
+ ```bash
432
+ cdx set grif --status-line off # Remove only cdx's command and keep it disabled.
433
+ cdx set grif --status-line on # Reinstall without replacing an operator command.
434
+ cdx unset grif --status-line # Return to the default (enabled for Claude).
435
+ cdx set --provider claude --status-line on # Update existing Claude profiles now.
436
+ ```
437
+
438
+ Codex has a built-in status line configured with `tui.status_line`, an ordered list
439
+ of built-in footer item identifiers, rather than Claude's shell-command setting
440
+ ([official configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference)).
441
+ cdx does not change Codex configuration for this feature.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.6
1
+ 0.26.8
@@ -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`).
@@ -0,0 +1,19 @@
1
+ # CDX Manager 0.26.8
2
+
3
+ Release date: 2026-11-07
4
+
5
+ ## Improvements
6
+
7
+ - Resolve the conversation of a live Codex process (#125,
8
+ `2150671b`). `cdx conversation <profile> --pid PID` and
9
+ `--process-identity ID` read the exact conversation of a native Codex
10
+ process, without falling back to workspace or recency matching.
11
+ - Add per-run provider arguments to `cdx run` (#120, `92620af`).
12
+ `--extra-args 'ARGS'` is validated like the session setting and appended
13
+ after the profile's arguments, without changing saved settings or
14
+ interactive launches.
15
+
16
+ ## Maintenance
17
+
18
+ - Record tray companion checksums for v0.26.7 (`bd53cb9`).
19
+ - Record release archive checksums for v0.26.7 (`145ec42`).
@@ -1,5 +1,23 @@
1
1
  # Unreleased
2
2
 
3
+ ## Per-run provider arguments (#120)
4
+
5
+ - Add `cdx run --extra-args 'ARGS'` with the session setting's validation and POSIX splitting, appended after the selected profile's arguments without changing saved settings or interactive launches. Preserve existing provider permission and isolation handling.
6
+ - Forward run arguments to detached supervisors and profile failover; reject Claude model, effort and conversation overrides during continuation. List the option under `cdx schema --json` unvalidated arguments and cover run-only Claude tool allowlists and strict MCP configuration.
7
+
8
+ ## Claude profile quota status line
9
+
10
+ - 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.
11
+ - 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.
12
+ - Write the standalone status line as UTF-8 when piped, including on Windows, and read repository contract fixtures explicitly as UTF-8.
13
+
14
+
15
+ ## Quota provenance and profile freshness
16
+
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+
3
21
  ## Find and resume interactive sessions
4
22
 
5
23
  - Add exact resolved `cdx history --cwd PATH` and `--interactive` filters before the result limit, preserving the JSON envelope and period/summary support.
@@ -859,6 +859,28 @@
859
859
  "x86_64-pc-windows-msvc": "1ea355b54e05ea6d78a3a23757a9ad5dd2228aa1d939fb7992082e16da579abb",
860
860
  "x86_64-unknown-linux-musl": "770fabb26784efb607d2cce6d6bee51c3d7121283c55cf4cacb9b54ac75e4450"
861
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
+ }
873
+ },
874
+ "v0.26.7": {
875
+ "github_tarball_sha256": "26e961c60c2473946cb5bfadec467b4d4ca9fd5a07cd313145cf4c4d427b1239",
876
+ "github_zip_sha256": "324c8f938f037e19c31b19e9fc6d4721ca822a7aa9a14d8e0e1af8da52aafaad",
877
+ "tray_assets": {
878
+ "aarch64-apple-darwin": "80088939f95fb6508f98b1e9a29643372dcc5e4cb2f72f06c5016f1708f6f526",
879
+ "aarch64-unknown-linux-musl": "29a044fa1241f38ce2892bd37388258ab1f10d66a74c3beef6462d85a8ab52c2",
880
+ "x86_64-apple-darwin": "d32be4180b361195ec9faef888dc9ff1a996bb74295882008ccb2b07dd16789d",
881
+ "x86_64-pc-windows-msvc": "098c27b535f041b72b6aac89d2c34464a986178be881336b89f99c8dfb5fdb5d",
882
+ "x86_64-unknown-linux-musl": "195737be7f25dc30bab676f53aed2c426612aee55de4cfc95a55a5b2d8ca9b76"
883
+ }
862
884
  }
863
885
  }
864
886
  }
package/docs/cli.md CHANGED
@@ -58,7 +58,7 @@
58
58
  | `cdx agents [--max-age-hours N] [--json]` | Each session's last known agent state (working, waiting, done, failed, limit, ended), waiting first; records older than N hours (default 24) are dropped. See [Agent state](sessions-and-notifications.md#agent-state) |
59
59
  | `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
60
60
  | `cdx select --provider PROVIDER [--min-reasoning-effort minimal\|low\|medium\|high\|xhigh] [--min-power minimal\|low\|medium\|high\|xhigh] [--require-ready] [--refresh] --json` | Select a suitable session for headless automation |
61
- | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--budget USD] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
61
+ | `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--budget USD] [--extra-args 'ARGS'] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
62
62
  | `cdx run-report <run_id> --json` | Full report, transcript metadata, and final payload for a run; `final_message` is the run's last assistant message, read from Claude's `result` envelope or Codex's last `agent_message` (null when neither exists) |
63
63
  | `cdx run-status <run_id> --json` | Status of one run by id |
64
64
  | `cdx run-tail <run_id> [--lines N] --json` | Last lines of a run's own output, while it is still running or after it finished |
@@ -683,3 +683,75 @@ Interactive launches appear as `status: "running"` as soon as the provider start
683
683
  History entries expose `conversation_id` and `provider_transcript_match: "conversation_id"` when an exact provider transcript exists. Claude uses its launch session ID; Codex can recover a unique thread from transcript metadata matching the launch directory and time window. Ambiguous discovery remains `provider_transcript_match: "recency"`, an explicitly unverified fallback that must not be treated as a verified resume target. A known ID with no transcript does not borrow another conversation. Existing history is enriched on read without rewriting old rows.
684
684
 
685
685
  `first_user_prompt` contains the first real user text (up to 2,000 characters) from a bounded, redacted transcript read. Provider command wrappers, system reminders, tool results and bare absolute file paths are skipped. Missing, oversized or unavailable evidence leaves the prompt unknown; a supplied initial prompt is used when available.
686
+
687
+ ### Resolve the conversation of a live Codex process
688
+
689
+ Consumers such as remote-ops can request process-scoped resolution without
690
+ falling back to workspace/recency:
691
+
692
+ ```bash
693
+ cdx conversation work2 --pid 12345 --tail 20 --json
694
+ cdx conversation work2 --process-identity 'linux:12345:<opaque digest from response>' --json
695
+ ```
696
+
697
+ Supply the native Codex PID, not the shell, tmux pane PID, Node launcher, or CDX
698
+ supervisor. CDX does not traverse descendants or choose between processes.
699
+ `work2` supplies the provider/profile boundary; its saved conversation is ignored
700
+ in process mode. The selectors are mutually exclusive and cannot be combined with
701
+ `--conversation`, `--cwd`, or `--since`.
702
+
703
+ The Python API is `read_conversation("codex", auth_home, pid=12345, tail=20)` in
704
+ `cdx_manager.conversation` (`src.conversation` in a source checkout). Retain
705
+ `process_identity` and pass it instead of `pid` on later calls to reject PID reuse.
706
+ A bare PID identifies its current occupant, not a previously observed process.
707
+ The opaque identity is a lifetime guard, not an authorization grant.
708
+
709
+ Process-selected pages expose:
710
+
711
+ | Field | Current contract |
712
+ | --- | --- |
713
+ | `resolution_status` | `unknown` until native selected-conversation evidence is available |
714
+ | `resolution_method` | `null`; kernel process identity alone is not a conversation resolution method |
715
+ | `conversation_id` | `null`; no candidate or guessed ID is exposed |
716
+ | `process_identity` | Opaque Linux lifetime guard when process validation succeeds |
717
+ | `reason` | Explicit evidence boundary or process validation failure |
718
+
719
+ **Current measured boundary:** process-to-selected-conversation resolution is not
720
+ available. Even a sole open writable rollout proves only that a process owns a
721
+ thread. During a TUI conversation switch it may belong to a retained thread while
722
+ the selected thread has no rollout yet. Resume argv, session/profile state, cwd,
723
+ mtime, a unique new transcript, and fuzzy text also cannot prove the current
724
+ selection. This implementation deliberately returns
725
+ `native_conversation_identity_unavailable` after validating a live Codex process;
726
+ it does not claim that an open file establishes the selected conversation.
727
+ Remote-ops must treat this as an explicit unknown, never retry via recency.
728
+
729
+ Process mode returns `status: "unavailable"`, empty entries, null conversation ID
730
+ and no cursors. With `--cursor` or `--before`, it returns `reset_required` and no
731
+ entries: a cursor from an explicit-ID or recency lookup cannot cross into process
732
+ mode. No process cursor is issued until it can be bound to both a verified process
733
+ lifetime and a native conversation selection/generation. Existing explicit-ID,
734
+ recency, forward, tail and backward pagination contracts remain unchanged.
735
+
736
+ Only Linux `/proc` process identity is currently supported. Each validation reads
737
+ at most 8 KiB each from process `stat`, `status` and kernel `boot_id`, twice, plus
738
+ the executable symlink/stat. The guard includes PID, start ticks, boot ID, all
739
+ four target UIDs and executable device/inode. Wrong user, a non-`codex` executable,
740
+ dead/reused PID, inaccessible/oversized/malformed evidence, or unsupported
741
+ provider/platform fails closed. Other reasons include `unrelated_user`,
742
+ `unrelated_process`, `process_not_running`, `process_identity_changed`,
743
+ `native_evidence_unavailable`, `evidence_limit`, and
744
+ `unsupported_process_resolution`. No argv, environment, credential store,
745
+ transcript file, directory scan or history database is read in process mode.
746
+ A process identity does not prove profile ownership; missing native profile and
747
+ thread association therefore remains unknown even for an existing profile.
748
+
749
+ The smallest required launch-time tracking is a provider-native event containing
750
+ the selected conversation ID on fresh start, resume and every selection change.
751
+ The launcher must bind each event atomically to the child's boot ID/PID/start ticks,
752
+ profile and a monotonic selection generation; invalidate it on exit. A future
753
+ resolver can consume that bounded record and bind cursors to the same generation.
754
+ An original resume ID or launch timestamp alone is insufficient. No launch/resume
755
+ flags or dependencies are changed here; implementing this requires a verified
756
+ provider lifecycle event source, which the inspected interactive interface does
757
+ not supply. See the mission fragment for the local native probe and Tower checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.6",
3
+ "version": "0.26.8",
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.6"
7
+ version = "0.26.8"
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,14 +266,14 @@ 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)}",
273
273
  f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
274
274
  f" {_style('cdx last [--json]', '36', use_color)}",
275
275
  f" {_style('cdx resume <name> [--project PATH] [--conversation <id>] [--json]', '36', use_color)}",
276
- f" {_style('cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
276
+ f" {_style('cdx conversation <name> [--conversation ID | --pid PID | --process-identity ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
277
277
  f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
278
278
  f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
279
279
  f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",