cdx-manager 0.9.16 → 0.10.0
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 +75 -3
- package/changelogs/CHANGELOGS_0_10_0.md +43 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +168 -2
- package/src/cli_args.py +1 -0
- package/src/cli_commands.py +513 -4
- package/src/cli_helpers.py +7 -0
- package/src/codex_usage.py +81 -16
- package/src/session_service.py +10 -0
- package/src/status_view.py +14 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -41,10 +41,12 @@ One command to launch any session. Zero auth juggling.
|
|
|
41
41
|
- **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
|
|
42
42
|
- **Persistent launch settings.** Pin per-session power, permission, and fast-mode preferences once; `cdx` reapplies them on every launch until you unset them.
|
|
43
43
|
- **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
|
|
44
|
+
- **Disk usage and cleanup.** `cdx disk` reports `CDX_HOME` usage, `cdx disk profiles --candidates` identifies reclaimable profile caches/logs with evidence, and `cdx clean profiles ...` applies explicit cleanup actions.
|
|
44
45
|
- **Update prompts.** Periodic update checks surface `cdx update` directly in the `cdx`, `cdx status`, and launch output when a newer release is available. When `logics-manager` is installed, `cdx` can also suggest `logics-manager self-update`.
|
|
45
46
|
- **Logics viewer shortcut.** `cdx view` opens the Logics browser/focus viewer through `logics-manager view` when the companion CLI is installed. All viewer flags are forwarded: `--lan`, `--lan-rw`, `--focus <ref>`, `--read`, `--port`, `--host`, `--refresh-interval`, `--tls`, `--tls-cert`, `--tls-key`, `--open`, `--no-open`. `cdx view --json` reports availability and update diagnostics without opening the viewer.
|
|
46
47
|
- **Shared handoff context.** Keep a per-workspace Markdown context, or build one from a source session transcript, and install it into another assistant session before switching providers or accounts.
|
|
47
48
|
- **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
|
|
49
|
+
- **Banked reset visibility.** Eligible Codex accounts show the number of manually redeemable bonus resets in `cdx status` and expose reset details in JSON output.
|
|
48
50
|
- **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
|
|
49
51
|
- **Clean removal.** `cdx rmv` wipes a session and its entire auth directory. No orphaned files, no stale credentials.
|
|
50
52
|
|
|
@@ -265,6 +267,10 @@ cdx next
|
|
|
265
267
|
|
|
266
268
|
# Notify when the next cooling-down assistant is ready
|
|
267
269
|
cdx ready
|
|
270
|
+
|
|
271
|
+
# Inspect CDX_HOME and profile disk usage
|
|
272
|
+
cdx disk
|
|
273
|
+
cdx disk profiles --candidates
|
|
268
274
|
```
|
|
269
275
|
|
|
270
276
|
### Next-Ready Notifications
|
|
@@ -358,7 +364,10 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
358
364
|
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
359
365
|
| `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports cross-provider handoff |
|
|
360
366
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
361
|
-
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
367
|
+
| `cdx clean [name] [--yes] [--json]` | Clear launch transcript logs for one session or all sessions after confirmation |
|
|
368
|
+
| `cdx clean profiles (--tmp\|--old-logs DAYS) [--yes] [--json]` | Remove explicit profile cleanup candidates after confirmation: temporary marketplace/plugin staging caches or old `.log` files |
|
|
369
|
+
| `cdx disk [profiles] [--candidates] [--json]` | Measure `CDX_HOME`; `profiles` includes per-profile breakdown, and `--candidates` reports reclaimable temporary caches and old logs with evidence |
|
|
370
|
+
| `cdx reset <name> [--yes] [--json]` | Explicitly consume one available banked Codex rate-limit reset; confirmation is required unless `--yes` is supplied |
|
|
362
371
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
363
372
|
| `cdx import <file> [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
|
|
364
373
|
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
@@ -394,6 +403,8 @@ Commands with machine-readable output:
|
|
|
394
403
|
- `cdx ren ... --json`
|
|
395
404
|
- `cdx rmv ... --json`
|
|
396
405
|
- `cdx clean ... --json`
|
|
406
|
+
- `cdx disk ... --json`
|
|
407
|
+
- `cdx reset ... --json`
|
|
397
408
|
- `cdx export ... --json`
|
|
398
409
|
- `cdx import ... --json`
|
|
399
410
|
- `cdx login ... --json`
|
|
@@ -602,7 +613,68 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
|
|
|
602
613
|
cdx-session.log
|
|
603
614
|
```
|
|
604
615
|
|
|
605
|
-
Session names are URL-encoded when used as directory or file names. CLI command names such as `add`, `status`, and `login` are reserved and cannot be used as session names.
|
|
616
|
+
Session names are URL-encoded when used as directory or file names. CLI command names such as `add`, `disk`, `status`, and `login` are reserved and cannot be used as new session names.
|
|
617
|
+
|
|
618
|
+
### Disk maintenance
|
|
619
|
+
|
|
620
|
+
Use `cdx disk` for a total `CDX_HOME` measurement, and `cdx disk profiles` for the `profiles/` total plus a per-profile breakdown.
|
|
621
|
+
|
|
622
|
+
In an interactive terminal, long scans report their current stage and per-profile progress on stderr. JSON and redirected output remain free of progress messages.
|
|
623
|
+
|
|
624
|
+
Text output uses aligned summary tables with profile size, share of total space, and reclaimable space. Candidate details are grouped by profile with separate size, type, risk, and evidence columns.
|
|
625
|
+
|
|
626
|
+
```bash
|
|
627
|
+
cdx disk
|
|
628
|
+
cdx disk profiles
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
To find cleanup candidates without deleting anything:
|
|
632
|
+
|
|
633
|
+
```bash
|
|
634
|
+
cdx disk profiles --candidates
|
|
635
|
+
cdx disk profiles --candidates --json
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Candidate evidence includes profile name, path, size, risk, reason, and metadata such as modified time or old-log file counts. The current cleanup candidates are intentionally limited to:
|
|
639
|
+
|
|
640
|
+
- temporary marketplace/plugin staging caches under `profiles/*/.tmp/`
|
|
641
|
+
- `.log` files older than the requested age inside profile `log/` directories
|
|
642
|
+
|
|
643
|
+
`cdx` also runs a passive disk cleanup check at most once per day on advisory commands such as `cdx` and `cdx status`. It writes the check timestamp and latest measurements to `~/.cdx/state/disk-cleanup-check.json`. A warning is shown only when at least one threshold is met:
|
|
644
|
+
|
|
645
|
+
- `CDX_HOME` uses 10 GB or more
|
|
646
|
+
- cleanup candidates can reclaim 1 GB or more
|
|
647
|
+
- temporary cache candidates can reclaim 500 MB or more
|
|
648
|
+
- old log candidates can reclaim 500 MB or more
|
|
649
|
+
- one profile is 2 GB or more, or one profile is at least 25% of `profiles/` when `profiles/` is already 1 GB or more
|
|
650
|
+
|
|
651
|
+
The passive warning is read-only and points to `cdx disk profiles --candidates`; it never deletes files.
|
|
652
|
+
|
|
653
|
+
Apply cleanup only with explicit actions:
|
|
654
|
+
|
|
655
|
+
```bash
|
|
656
|
+
cdx clean profiles --tmp
|
|
657
|
+
cdx clean profiles --old-logs 30d
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`--tmp` removes temporary marketplace/plugin clone/backup staging directories. `--old-logs 30d` removes only `.log` files older than 30 days. The commands do not remove `auth.json`, `config.toml`, `sessions/`, SQLite state, installed `plugins/`, `skills/`, or credentials.
|
|
661
|
+
|
|
662
|
+
All `cdx clean` actions require interactive confirmation before any deletion or log truncation begins. Non-interactive scripts must pass `--yes` explicitly.
|
|
663
|
+
|
|
664
|
+
The cleanup action is selected only when `--tmp` or `--old-logs` is present. This keeps `cdx clean profiles` available for an existing session named `profiles`. If any candidate or old log cannot be removed, cleanup stops with a non-zero error that identifies the affected path; it does not report the operation as successful.
|
|
665
|
+
|
|
666
|
+
### Banked Codex resets
|
|
667
|
+
|
|
668
|
+
Eligible Codex accounts expose manually redeemable rate-limit resets through the Codex app-server. `cdx status` shows their available count in the `RESETS` column; `cdx status <name>` also shows the nearest known expiration, and JSON status includes the available reset details supplied by Codex.
|
|
669
|
+
|
|
670
|
+
Activation is always explicit:
|
|
671
|
+
|
|
672
|
+
```bash
|
|
673
|
+
cdx reset <name>
|
|
674
|
+
cdx reset <name> --yes --json
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Without `--yes`, an interactive confirmation is required. `cdx` sends an idempotency key, consumes at most one available reset, and refreshes the session status after a successful activation. It never activates a reset from `cdx status` or any background check.
|
|
606
678
|
|
|
607
679
|
---
|
|
608
680
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# CDX Manager 0.10.0
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Inspect `CDX_HOME` and per-profile disk usage with readable, progress-aware reports.
|
|
6
|
+
- Find evidence-backed cleanup candidates and reclaim space through confirmed actions.
|
|
7
|
+
- Display and explicitly activate banked Codex rate-limit resets.
|
|
8
|
+
|
|
9
|
+
## Changes
|
|
10
|
+
|
|
11
|
+
### Disk usage and cleanup
|
|
12
|
+
|
|
13
|
+
`cdx disk` measures total `CDX_HOME` usage. `cdx disk profiles` adds an aligned per-profile table with size and share of total space, while `--candidates` adds reclaimable totals and evidence grouped by profile.
|
|
14
|
+
|
|
15
|
+
Interactive scans report their current stage and profile count on stderr so large profiles no longer appear stalled. JSON and redirected output remain unchanged.
|
|
16
|
+
|
|
17
|
+
Cleanup candidates are intentionally limited to temporary marketplace/plugin staging directories and old `.log` files. `cdx clean profiles --tmp` and `cdx clean profiles --old-logs DAYS` require confirmation before deletion; all other `cdx clean` log-truncation actions now require confirmation as well. Non-interactive automation must opt in with `--yes`.
|
|
18
|
+
|
|
19
|
+
A passive read-only check runs at most once per day and warns when documented total, reclaimable, category, or large-profile thresholds are reached. It never removes files automatically.
|
|
20
|
+
|
|
21
|
+
### Banked Codex resets
|
|
22
|
+
|
|
23
|
+
Eligible Codex accounts now show banked rate-limit reset counts in `cdx status`. Detailed JSON includes reset identifiers, labels, grant timestamps, and expiration timestamps when Codex provides them.
|
|
24
|
+
|
|
25
|
+
`cdx reset <name>` consumes one reset through Codex's idempotent reset-credit endpoint, requires confirmation unless `--yes` is supplied, and refreshes status after activation. Status checks never consume resets automatically.
|
|
26
|
+
|
|
27
|
+
### Compatibility and safeguards
|
|
28
|
+
|
|
29
|
+
`disk` and `reset` are reserved for new session names. Existing sessions named `profiles` remain compatible with `cdx clean profiles`; profile-wide cleanup is selected only when `--tmp` or `--old-logs` is present.
|
|
30
|
+
|
|
31
|
+
Cleanup failures now stop with a non-zero error naming the affected path instead of silently reporting zero bytes removed.
|
|
32
|
+
|
|
33
|
+
## Validation
|
|
34
|
+
|
|
35
|
+
- `npm run release:validate`
|
|
36
|
+
- `npm run lint`
|
|
37
|
+
- `npm test`
|
|
38
|
+
- `logics-manager lint --require-status`
|
|
39
|
+
- `logics-manager audit`
|
|
40
|
+
- `git diff --check`
|
|
41
|
+
- `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
|
|
42
|
+
- `python -m build`
|
|
43
|
+
- `python -m twine check dist/*`
|
|
@@ -128,6 +128,10 @@
|
|
|
128
128
|
"v0.9.15": {
|
|
129
129
|
"github_tarball_sha256": "08e6c5a3459fdd65d1f6085ca03a3614f07b4bf44c57a995a3ef47446aa70482",
|
|
130
130
|
"github_zip_sha256": "c8ac01f4a65e85b417f5eba5139868ab17b8f0952bf99f17f0a79f73ef0f36d6"
|
|
131
|
+
},
|
|
132
|
+
"v0.9.16": {
|
|
133
|
+
"github_tarball_sha256": "0876509c201485824f6e5fe7b4a96ceb41cc68279fbe6dda4891864cf5be93ec",
|
|
134
|
+
"github_zip_sha256": "08072957a0421d257cecfa8bed73cceee80d1c8857c59ac45378ac8ce3e0362d"
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
|
|
7
8
|
from .cli_commands import (
|
|
8
9
|
API_SCHEMA_VERSION,
|
|
9
10
|
STATUS_USAGE,
|
|
11
|
+
_collect_profile_cleanup_candidates,
|
|
12
|
+
_directory_child_sizes,
|
|
13
|
+
_directory_size_bytes,
|
|
10
14
|
handle_add,
|
|
11
15
|
handle_can_resume,
|
|
12
16
|
handle_clean,
|
|
@@ -15,6 +19,7 @@ from .cli_commands import (
|
|
|
15
19
|
handle_context,
|
|
16
20
|
handle_copy,
|
|
17
21
|
handle_disable,
|
|
22
|
+
handle_disk,
|
|
18
23
|
handle_doctor,
|
|
19
24
|
handle_enable,
|
|
20
25
|
handle_export,
|
|
@@ -31,6 +36,7 @@ from .cli_commands import (
|
|
|
31
36
|
handle_remove,
|
|
32
37
|
handle_rename,
|
|
33
38
|
handle_repair,
|
|
39
|
+
handle_reset,
|
|
34
40
|
handle_resume,
|
|
35
41
|
handle_run,
|
|
36
42
|
handle_run_report,
|
|
@@ -44,6 +50,9 @@ from .cli_commands import (
|
|
|
44
50
|
handle_update,
|
|
45
51
|
handle_view,
|
|
46
52
|
)
|
|
53
|
+
from .cli_commands import (
|
|
54
|
+
_format_bytes as _format_disk_bytes,
|
|
55
|
+
)
|
|
47
56
|
from .cli_render import (
|
|
48
57
|
_format_sessions,
|
|
49
58
|
_pad_table,
|
|
@@ -66,7 +75,7 @@ from .status_view import (
|
|
|
66
75
|
)
|
|
67
76
|
from .update_check import check_for_update, check_logics_manager_for_update
|
|
68
77
|
|
|
69
|
-
VERSION = "0.
|
|
78
|
+
VERSION = "0.10.0"
|
|
70
79
|
|
|
71
80
|
# Public surface: this module is a facade. Names below are imported above
|
|
72
81
|
# purely to be re-exported (consumed by tests and external callers); listing
|
|
@@ -94,6 +103,7 @@ _COMMAND_HANDLERS = {
|
|
|
94
103
|
"config": handle_config,
|
|
95
104
|
"configs": handle_configs,
|
|
96
105
|
"context": handle_context,
|
|
106
|
+
"disk": handle_disk,
|
|
97
107
|
"doctor": handle_doctor,
|
|
98
108
|
"disable": handle_disable,
|
|
99
109
|
"enable": handle_enable,
|
|
@@ -107,6 +117,7 @@ _COMMAND_HANDLERS = {
|
|
|
107
117
|
"next": handle_next,
|
|
108
118
|
"notify": handle_notify,
|
|
109
119
|
"repair": handle_repair,
|
|
120
|
+
"reset": handle_reset,
|
|
110
121
|
"rename": handle_rename,
|
|
111
122
|
"resume": handle_resume,
|
|
112
123
|
"can-resume": handle_can_resume,
|
|
@@ -165,12 +176,14 @@ def _print_help(use_color=False):
|
|
|
165
176
|
f" {_style('cdx status --small|-s [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
|
|
166
177
|
f" {_style('cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]', '36', use_color)}",
|
|
167
178
|
f" {_style('cdx next [--json] [--refresh]', '36', use_color)}",
|
|
179
|
+
f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
|
|
168
180
|
f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
|
|
169
181
|
f" {_style('cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json', '36', use_color)}",
|
|
170
182
|
f" {_style('cdx runs [--limit N] --json', '36', use_color)}",
|
|
171
183
|
f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
|
|
172
184
|
f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
|
|
173
185
|
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
186
|
+
f" {_style('cdx disk [profiles] [--candidates] [--json]', '36', use_color)}",
|
|
174
187
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
175
188
|
f" {_style('cdx configs [--json]', '36', use_color)}",
|
|
176
189
|
f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
|
|
@@ -191,7 +204,8 @@ def _print_help(use_color=False):
|
|
|
191
204
|
f" {_style('cdx disable <name> [--json]', '36', use_color)}",
|
|
192
205
|
f" {_style('cdx enable <name> [--json]', '36', use_color)}",
|
|
193
206
|
f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
|
|
194
|
-
f" {_style('cdx clean [name] [--json]', '36', use_color)}",
|
|
207
|
+
f" {_style('cdx clean [name] [--yes] [--json]', '36', use_color)}",
|
|
208
|
+
f" {_style('cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]', '36', use_color)}",
|
|
195
209
|
f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force] [--json]', '36', use_color)}",
|
|
196
210
|
f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force|--merge] [--json]', '36', use_color)}",
|
|
197
211
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
@@ -264,9 +278,154 @@ def _get_update_notices(service, env, options):
|
|
|
264
278
|
)
|
|
265
279
|
if logics_notice:
|
|
266
280
|
notices.append(logics_notice)
|
|
281
|
+
disk_notice = _get_disk_cleanup_notice(service, options)
|
|
282
|
+
if disk_notice:
|
|
283
|
+
notices.append(disk_notice)
|
|
267
284
|
return notices
|
|
268
285
|
|
|
269
286
|
|
|
287
|
+
_DISK_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
|
288
|
+
_DISK_HOME_THRESHOLD_BYTES = 10 * 1024 * 1024 * 1024
|
|
289
|
+
_DISK_RECLAIMABLE_THRESHOLD_BYTES = 1 * 1024 * 1024 * 1024
|
|
290
|
+
_DISK_CATEGORY_THRESHOLD_BYTES = 500 * 1024 * 1024
|
|
291
|
+
_DISK_PROFILE_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _now_utc(options):
|
|
295
|
+
now_fn = options.get("now")
|
|
296
|
+
value = now_fn() if callable(now_fn) else None
|
|
297
|
+
if isinstance(value, datetime):
|
|
298
|
+
return value.astimezone(timezone.utc)
|
|
299
|
+
return datetime.now(timezone.utc)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _read_json_file(path):
|
|
303
|
+
try:
|
|
304
|
+
with open(path, encoding="utf-8") as handle:
|
|
305
|
+
return json.load(handle)
|
|
306
|
+
except (OSError, ValueError):
|
|
307
|
+
return {}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _write_json_file(path, payload):
|
|
311
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
312
|
+
tmp_path = f"{path}.tmp"
|
|
313
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
314
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
315
|
+
handle.write("\n")
|
|
316
|
+
os.replace(tmp_path, path)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _parse_iso_datetime(value):
|
|
320
|
+
if not value:
|
|
321
|
+
return None
|
|
322
|
+
try:
|
|
323
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
324
|
+
except ValueError:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _get_disk_cleanup_notice(service, options):
|
|
329
|
+
checker = options.get("checkDiskCleanup")
|
|
330
|
+
if callable(checker):
|
|
331
|
+
return checker(service, options)
|
|
332
|
+
|
|
333
|
+
base_dir = service["base_dir"]
|
|
334
|
+
state_path = os.path.join(base_dir, "state", "disk-cleanup-check.json")
|
|
335
|
+
now = _now_utc(options)
|
|
336
|
+
state = _read_json_file(state_path)
|
|
337
|
+
last_checked = _parse_iso_datetime(state.get("last_checked_at"))
|
|
338
|
+
if last_checked and (now - last_checked).total_seconds() < _DISK_CHECK_INTERVAL_SECONDS:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
runner = options.get("diskUsageRunner")
|
|
342
|
+
try:
|
|
343
|
+
home_bytes = _directory_size_bytes(base_dir, runner=runner)
|
|
344
|
+
profiles_dir = os.path.join(base_dir, "profiles")
|
|
345
|
+
profile_rows = _directory_child_sizes(profiles_dir, runner=runner)
|
|
346
|
+
profile_total = sum(row["bytes"] for row in profile_rows)
|
|
347
|
+
candidates = _collect_profile_cleanup_candidates(base_dir, runner=runner, now=now.timestamp())
|
|
348
|
+
except Exception:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
reclaimable_bytes = sum(item["bytes"] for item in candidates)
|
|
352
|
+
tmp_bytes = sum(item["bytes"] for item in candidates if item["kind"].startswith("tmp-"))
|
|
353
|
+
old_log_bytes = sum(item["bytes"] for item in candidates if item["kind"].startswith("old-logs-"))
|
|
354
|
+
large_profiles = [
|
|
355
|
+
{
|
|
356
|
+
"name": row["name"],
|
|
357
|
+
"bytes": row["bytes"],
|
|
358
|
+
"size": _format_disk_bytes(row["bytes"]),
|
|
359
|
+
"share_pct": round((row["bytes"] / profile_total) * 100, 1) if profile_total else 0,
|
|
360
|
+
}
|
|
361
|
+
for row in profile_rows
|
|
362
|
+
if row["bytes"] >= _DISK_PROFILE_THRESHOLD_BYTES
|
|
363
|
+
or (
|
|
364
|
+
profile_total >= _DISK_RECLAIMABLE_THRESHOLD_BYTES
|
|
365
|
+
and row["bytes"] / profile_total >= 0.25
|
|
366
|
+
)
|
|
367
|
+
]
|
|
368
|
+
triggered = (
|
|
369
|
+
home_bytes >= _DISK_HOME_THRESHOLD_BYTES
|
|
370
|
+
or reclaimable_bytes >= _DISK_RECLAIMABLE_THRESHOLD_BYTES
|
|
371
|
+
or tmp_bytes >= _DISK_CATEGORY_THRESHOLD_BYTES
|
|
372
|
+
or old_log_bytes >= _DISK_CATEGORY_THRESHOLD_BYTES
|
|
373
|
+
or bool(large_profiles)
|
|
374
|
+
)
|
|
375
|
+
state_payload = {
|
|
376
|
+
"last_checked_at": now.isoformat(),
|
|
377
|
+
"home_bytes": home_bytes,
|
|
378
|
+
"reclaimable_bytes": reclaimable_bytes,
|
|
379
|
+
"tmp_bytes": tmp_bytes,
|
|
380
|
+
"old_log_bytes": old_log_bytes,
|
|
381
|
+
"large_profiles": large_profiles,
|
|
382
|
+
}
|
|
383
|
+
try:
|
|
384
|
+
_write_json_file(state_path, state_payload)
|
|
385
|
+
except OSError:
|
|
386
|
+
pass
|
|
387
|
+
if not triggered:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
if reclaimable_bytes > 0:
|
|
391
|
+
message = (
|
|
392
|
+
f"Disk cleanup available: {_format_disk_bytes(reclaimable_bytes)} reclaimable "
|
|
393
|
+
f"in CDX profiles. Inspect: cdx disk profiles --candidates"
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
message = (
|
|
397
|
+
f"CDX_HOME uses {_format_disk_bytes(home_bytes)}. "
|
|
398
|
+
"Inspect: cdx disk profiles --candidates"
|
|
399
|
+
)
|
|
400
|
+
if tmp_bytes >= _DISK_CATEGORY_THRESHOLD_BYTES:
|
|
401
|
+
message = (
|
|
402
|
+
f"{message}. Safe temp cleanup: {_format_disk_bytes(tmp_bytes)} "
|
|
403
|
+
"with cdx clean profiles --tmp"
|
|
404
|
+
)
|
|
405
|
+
if old_log_bytes >= _DISK_CATEGORY_THRESHOLD_BYTES:
|
|
406
|
+
message = (
|
|
407
|
+
f"{message}. Old logs: {_format_disk_bytes(old_log_bytes)} "
|
|
408
|
+
"with cdx clean profiles --old-logs 30d"
|
|
409
|
+
)
|
|
410
|
+
return {
|
|
411
|
+
"tool": "cdx-disk",
|
|
412
|
+
"code": "disk_cleanup_available",
|
|
413
|
+
"message": message,
|
|
414
|
+
"reclaimable_bytes": reclaimable_bytes,
|
|
415
|
+
"reclaimable_size": _format_disk_bytes(reclaimable_bytes),
|
|
416
|
+
"home_bytes": home_bytes,
|
|
417
|
+
"home_size": _format_disk_bytes(home_bytes),
|
|
418
|
+
"tmp_bytes": tmp_bytes,
|
|
419
|
+
"tmp_size": _format_disk_bytes(tmp_bytes),
|
|
420
|
+
"old_log_bytes": old_log_bytes,
|
|
421
|
+
"old_log_size": _format_disk_bytes(old_log_bytes),
|
|
422
|
+
"large_profiles": large_profiles,
|
|
423
|
+
"inspect_command": "cdx disk profiles --candidates",
|
|
424
|
+
"cleanup_tmp_command": "cdx clean profiles --tmp" if tmp_bytes >= _DISK_CATEGORY_THRESHOLD_BYTES else None,
|
|
425
|
+
"cleanup_old_logs_command": "cdx clean profiles --old-logs 30d" if old_log_bytes >= _DISK_CATEGORY_THRESHOLD_BYTES else None,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
270
429
|
def _update_warning_payload(notices):
|
|
271
430
|
if isinstance(notices, dict):
|
|
272
431
|
notices = [notices]
|
|
@@ -274,6 +433,13 @@ def _update_warning_payload(notices):
|
|
|
274
433
|
return []
|
|
275
434
|
warnings = []
|
|
276
435
|
for notice in notices:
|
|
436
|
+
if notice.get("code") == "disk_cleanup_available":
|
|
437
|
+
warnings.append({
|
|
438
|
+
"code": "disk_cleanup_available",
|
|
439
|
+
"message": notice["message"],
|
|
440
|
+
**{key: value for key, value in notice.items() if key not in ("code", "message")},
|
|
441
|
+
})
|
|
442
|
+
continue
|
|
277
443
|
tool = notice.get("tool") or "cdx-manager"
|
|
278
444
|
current = notice.get("current_version") or VERSION
|
|
279
445
|
command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
|
package/src/cli_args.py
CHANGED
|
@@ -13,6 +13,7 @@ from .provider_runtime import _normalize_reasoning_effort
|
|
|
13
13
|
|
|
14
14
|
STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
|
|
15
15
|
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
16
|
+
DISK_USAGE = "Usage: cdx disk [profiles] [--candidates] [--json]"
|
|
16
17
|
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
17
18
|
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
18
19
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
package/src/cli_commands.py
CHANGED
|
@@ -4,8 +4,11 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
7
9
|
import sys
|
|
8
10
|
import time
|
|
11
|
+
import uuid
|
|
9
12
|
from datetime import datetime
|
|
10
13
|
|
|
11
14
|
from .backup_bundle import read_bundle_meta
|
|
@@ -13,6 +16,7 @@ from .claude_refresh import _refresh_claude_sessions
|
|
|
13
16
|
from .cli_args import (
|
|
14
17
|
CAN_RESUME_USAGE,
|
|
15
18
|
CONTEXT_USAGE,
|
|
19
|
+
DISK_USAGE,
|
|
16
20
|
DOCTOR_USAGE,
|
|
17
21
|
HANDOFF_USAGE,
|
|
18
22
|
LAST_USAGE,
|
|
@@ -66,9 +70,10 @@ from .cli_helpers import (
|
|
|
66
70
|
_write_json,
|
|
67
71
|
_write_update_notice,
|
|
68
72
|
)
|
|
69
|
-
from .cli_render import _dim, _info, _style, _success, _warn
|
|
73
|
+
from .cli_render import _dim, _info, _pad_table, _style, _success, _warn
|
|
70
74
|
from .cli_view import handle_view as handle_view # re-export for cli.py / tests
|
|
71
|
-
from .
|
|
75
|
+
from .codex_usage import consume_codex_rate_limit_reset_credit
|
|
76
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
72
77
|
from .context_store import (
|
|
73
78
|
clear_context,
|
|
74
79
|
edit_context,
|
|
@@ -140,6 +145,21 @@ def _confirm_removal(name):
|
|
|
140
145
|
return answer.strip().lower() in ("y", "yes")
|
|
141
146
|
|
|
142
147
|
|
|
148
|
+
def _confirm_reset(name):
|
|
149
|
+
answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
|
|
150
|
+
return answer.strip().lower() in ("y", "yes")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _confirm_profile_cleanup(action):
|
|
154
|
+
answer = input(f"Delete matching profile {action} candidates? [y/N] ")
|
|
155
|
+
return answer.strip().lower() in ("y", "yes")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _confirm_log_cleanup(target):
|
|
159
|
+
answer = input(f"Clear launch transcript logs for {target}? [y/N] ")
|
|
160
|
+
return answer.strip().lower() in ("y", "yes")
|
|
161
|
+
|
|
162
|
+
|
|
143
163
|
def _resolve_confirmation(confirm_fn, name):
|
|
144
164
|
confirmed = (
|
|
145
165
|
asyncio.get_event_loop().run_until_complete(confirm_fn(name))
|
|
@@ -151,6 +171,412 @@ def _resolve_confirmation(confirm_fn, name):
|
|
|
151
171
|
return confirmed
|
|
152
172
|
|
|
153
173
|
|
|
174
|
+
def _format_bytes(value):
|
|
175
|
+
size = float(value)
|
|
176
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
177
|
+
if size < 1024 or unit == "TB":
|
|
178
|
+
return f"{int(size)} {unit}" if unit == "B" or size.is_integer() else f"{size:.1f} {unit}"
|
|
179
|
+
size /= 1024
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _directory_size_bytes(path, runner=None):
|
|
183
|
+
if not os.path.exists(path):
|
|
184
|
+
raise CdxError(f"CDX home does not exist: {path}")
|
|
185
|
+
runner = runner or subprocess.check_output
|
|
186
|
+
try:
|
|
187
|
+
output = runner(["du", "-sk", path], text=True, stderr=subprocess.DEVNULL)
|
|
188
|
+
return int(str(output).split()[0]) * 1024
|
|
189
|
+
except (OSError, subprocess.CalledProcessError, ValueError, IndexError):
|
|
190
|
+
total = 0
|
|
191
|
+
for root, _, files in os.walk(path):
|
|
192
|
+
for name in files:
|
|
193
|
+
try:
|
|
194
|
+
total += os.path.getsize(os.path.join(root, name))
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
return total
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _directory_child_sizes(path, runner=None, progress=None):
|
|
201
|
+
rows = []
|
|
202
|
+
try:
|
|
203
|
+
names = os.listdir(path)
|
|
204
|
+
except OSError:
|
|
205
|
+
return rows
|
|
206
|
+
children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
|
|
207
|
+
for index, (name, child_path) in enumerate(children, start=1):
|
|
208
|
+
if progress:
|
|
209
|
+
progress("profile", name, index, len(children))
|
|
210
|
+
rows.append({
|
|
211
|
+
"name": name,
|
|
212
|
+
"path": child_path,
|
|
213
|
+
"bytes": _directory_size_bytes(child_path, runner=runner),
|
|
214
|
+
})
|
|
215
|
+
rows.sort(key=lambda row: row["bytes"], reverse=True)
|
|
216
|
+
for row in rows:
|
|
217
|
+
row["size"] = _format_bytes(row["bytes"])
|
|
218
|
+
return rows
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_days(value, usage):
|
|
222
|
+
match = re.fullmatch(r"(\d+)(?:d)?", str(value or "").strip())
|
|
223
|
+
if not match or int(match.group(1)) <= 0:
|
|
224
|
+
raise CdxError(usage)
|
|
225
|
+
return int(match.group(1))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _candidate(profile, kind, path, bytes_used, reason, risk="safe", evidence=None):
|
|
229
|
+
return {
|
|
230
|
+
"profile": profile,
|
|
231
|
+
"kind": kind,
|
|
232
|
+
"path": path,
|
|
233
|
+
"bytes": bytes_used,
|
|
234
|
+
"size": _format_bytes(bytes_used),
|
|
235
|
+
"reason": reason,
|
|
236
|
+
"risk": risk,
|
|
237
|
+
"evidence": evidence or {},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _iter_profile_dirs(base_dir):
|
|
242
|
+
profiles_dir = os.path.join(base_dir, "profiles")
|
|
243
|
+
try:
|
|
244
|
+
names = sorted(os.listdir(profiles_dir))
|
|
245
|
+
except OSError:
|
|
246
|
+
return
|
|
247
|
+
for name in names:
|
|
248
|
+
path = os.path.join(profiles_dir, name)
|
|
249
|
+
if os.path.isdir(path):
|
|
250
|
+
yield name, path
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _collect_old_logs(profile_name, profile_path, days, now=None):
|
|
254
|
+
cutoff = (time.time() if now is None else now) - (days * 86400)
|
|
255
|
+
paths = []
|
|
256
|
+
bytes_used = 0
|
|
257
|
+
latest_mtime = 0
|
|
258
|
+
for root, _, files in os.walk(profile_path):
|
|
259
|
+
if os.path.basename(root) != "log":
|
|
260
|
+
continue
|
|
261
|
+
for filename in files:
|
|
262
|
+
if not filename.endswith(".log"):
|
|
263
|
+
continue
|
|
264
|
+
path = os.path.join(root, filename)
|
|
265
|
+
try:
|
|
266
|
+
stat = os.stat(path)
|
|
267
|
+
except OSError:
|
|
268
|
+
continue
|
|
269
|
+
if stat.st_mtime >= cutoff:
|
|
270
|
+
continue
|
|
271
|
+
paths.append(path)
|
|
272
|
+
bytes_used += stat.st_size
|
|
273
|
+
latest_mtime = max(latest_mtime, stat.st_mtime)
|
|
274
|
+
if not paths:
|
|
275
|
+
return None
|
|
276
|
+
return _candidate(
|
|
277
|
+
profile_name,
|
|
278
|
+
f"old-logs-{days}d",
|
|
279
|
+
profile_path,
|
|
280
|
+
bytes_used,
|
|
281
|
+
f"{len(paths)} .log files older than {days}d",
|
|
282
|
+
risk="review",
|
|
283
|
+
evidence={
|
|
284
|
+
"days": days,
|
|
285
|
+
"file_count": len(paths),
|
|
286
|
+
"newest_old_log_mtime": datetime.fromtimestamp(latest_mtime).isoformat() if latest_mtime else None,
|
|
287
|
+
"sample_paths": paths[:5],
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
|
|
293
|
+
candidates = []
|
|
294
|
+
profiles = list(_iter_profile_dirs(base_dir))
|
|
295
|
+
for index, (profile_name, profile_path) in enumerate(profiles, start=1):
|
|
296
|
+
if progress:
|
|
297
|
+
progress("candidates", profile_name, index, len(profiles))
|
|
298
|
+
tmp_dir = os.path.join(profile_path, ".tmp")
|
|
299
|
+
tmp_targets = [
|
|
300
|
+
("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
|
|
301
|
+
]
|
|
302
|
+
try:
|
|
303
|
+
tmp_names = os.listdir(tmp_dir)
|
|
304
|
+
except OSError:
|
|
305
|
+
tmp_names = []
|
|
306
|
+
for name in tmp_names:
|
|
307
|
+
if name.startswith("plugins-clone-"):
|
|
308
|
+
tmp_targets.append(("tmp-plugin-clone", os.path.join(tmp_dir, name), "temporary plugin clone"))
|
|
309
|
+
elif name.startswith("plugins-backup-"):
|
|
310
|
+
tmp_targets.append(("tmp-plugin-backup", os.path.join(tmp_dir, name), "temporary plugin backup"))
|
|
311
|
+
for kind, path, reason in tmp_targets:
|
|
312
|
+
if not os.path.exists(path):
|
|
313
|
+
continue
|
|
314
|
+
bytes_used = _directory_size_bytes(path, runner=runner)
|
|
315
|
+
if bytes_used <= 0:
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
|
|
319
|
+
except OSError:
|
|
320
|
+
mtime = None
|
|
321
|
+
candidates.append(_candidate(
|
|
322
|
+
profile_name,
|
|
323
|
+
kind,
|
|
324
|
+
path,
|
|
325
|
+
bytes_used,
|
|
326
|
+
reason,
|
|
327
|
+
evidence={"modified_at": mtime},
|
|
328
|
+
))
|
|
329
|
+
old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
|
|
330
|
+
if old_logs and old_logs["bytes"] > 0:
|
|
331
|
+
candidates.append(old_logs)
|
|
332
|
+
candidates.sort(key=lambda item: item["bytes"], reverse=True)
|
|
333
|
+
return candidates
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _format_cleanup_candidates(candidates, use_color=False):
|
|
337
|
+
if not candidates:
|
|
338
|
+
return _dim("No cleanup candidates found.", use_color)
|
|
339
|
+
reclaimable = sum(item["bytes"] for item in candidates)
|
|
340
|
+
lines = [
|
|
341
|
+
_style("Cleanup candidates", "1", use_color),
|
|
342
|
+
f"{_style(_format_bytes(reclaimable), '33', use_color)} reclaimable across {len({item['profile'] for item in candidates})} profile(s)",
|
|
343
|
+
]
|
|
344
|
+
by_profile = {}
|
|
345
|
+
for item in candidates:
|
|
346
|
+
by_profile.setdefault(item["profile"], []).append(item)
|
|
347
|
+
for profile, items in sorted(by_profile.items(), key=lambda row: sum(i["bytes"] for i in row[1]), reverse=True):
|
|
348
|
+
total = sum(item["bytes"] for item in items)
|
|
349
|
+
lines.extend(["", f"{_style(profile, '1', use_color)} {_style(_format_bytes(total), '33', use_color)}"])
|
|
350
|
+
rows = [[
|
|
351
|
+
_style("SIZE", "1", use_color),
|
|
352
|
+
_style("TYPE", "1", use_color),
|
|
353
|
+
_style("RISK", "1", use_color),
|
|
354
|
+
_style("EVIDENCE", "1", use_color),
|
|
355
|
+
]]
|
|
356
|
+
for item in items:
|
|
357
|
+
risk_color = "32" if item["risk"] == "safe" else "33"
|
|
358
|
+
rows.append([
|
|
359
|
+
_style(item["size"], "36", use_color),
|
|
360
|
+
item["kind"],
|
|
361
|
+
_style(item["risk"], risk_color, use_color),
|
|
362
|
+
item["reason"],
|
|
363
|
+
])
|
|
364
|
+
lines.append(_pad_table(rows))
|
|
365
|
+
return "\n".join(lines)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _format_disk_report(payload, use_color=False):
|
|
369
|
+
title = "CDX profiles" if payload["target"] == "profiles" else "CDX home"
|
|
370
|
+
lines = [
|
|
371
|
+
_style(title, "1", use_color),
|
|
372
|
+
f"Path: {_dim(payload['path'], use_color)}",
|
|
373
|
+
f"Total: {_style(payload['size'], '36', use_color)}",
|
|
374
|
+
]
|
|
375
|
+
children = payload.get("children") or []
|
|
376
|
+
if children:
|
|
377
|
+
reclaimable_by_profile = {}
|
|
378
|
+
for item in payload.get("candidates") or []:
|
|
379
|
+
reclaimable_by_profile[item["profile"]] = reclaimable_by_profile.get(item["profile"], 0) + item["bytes"]
|
|
380
|
+
headers = ["PROFILE", "SIZE", "SHARE"]
|
|
381
|
+
if "candidates" in payload:
|
|
382
|
+
headers.append("RECLAIMABLE")
|
|
383
|
+
rows = [[_style(header, "1", use_color) for header in headers]]
|
|
384
|
+
for child in children:
|
|
385
|
+
share = (child["bytes"] / payload["bytes"] * 100) if payload["bytes"] else 0
|
|
386
|
+
row = [
|
|
387
|
+
_style(child["name"], "1", use_color),
|
|
388
|
+
_style(child["size"], "36", use_color),
|
|
389
|
+
f"{share:.1f}%",
|
|
390
|
+
]
|
|
391
|
+
if "candidates" in payload:
|
|
392
|
+
reclaimable = reclaimable_by_profile.get(child["name"], 0)
|
|
393
|
+
row.append(_style(_format_bytes(reclaimable), "33", use_color) if reclaimable else _dim("-", use_color))
|
|
394
|
+
rows.append(row)
|
|
395
|
+
lines.extend(["", _style(f"Profiles ({len(children)})", "1", use_color), _pad_table(rows)])
|
|
396
|
+
if "candidates" in payload:
|
|
397
|
+
lines.extend(["", _format_cleanup_candidates(payload["candidates"], use_color=use_color)])
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _remove_path(path):
|
|
402
|
+
try:
|
|
403
|
+
if os.path.isdir(path):
|
|
404
|
+
size = _directory_size_bytes(path)
|
|
405
|
+
shutil.rmtree(path)
|
|
406
|
+
else:
|
|
407
|
+
size = os.path.getsize(path)
|
|
408
|
+
os.remove(path)
|
|
409
|
+
return size
|
|
410
|
+
except OSError as error:
|
|
411
|
+
raise CdxError(f"Failed to remove cleanup candidate {path}: {error}") from error
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _clean_profile_tmp(profile_path):
|
|
415
|
+
tmp_dir = os.path.join(profile_path, ".tmp")
|
|
416
|
+
targets = [os.path.join(tmp_dir, "marketplaces")]
|
|
417
|
+
try:
|
|
418
|
+
names = os.listdir(tmp_dir)
|
|
419
|
+
except OSError:
|
|
420
|
+
names = []
|
|
421
|
+
targets.extend(os.path.join(tmp_dir, name) for name in names if name.startswith(("plugins-clone-", "plugins-backup-")))
|
|
422
|
+
freed = 0
|
|
423
|
+
removed = []
|
|
424
|
+
for path in targets:
|
|
425
|
+
if os.path.exists(path):
|
|
426
|
+
size = _remove_path(path)
|
|
427
|
+
if size:
|
|
428
|
+
freed += size
|
|
429
|
+
removed.append(path)
|
|
430
|
+
return freed, removed
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _clean_profile_old_logs(profile_path, days, now=None):
|
|
434
|
+
cutoff = (time.time() if now is None else now) - (days * 86400)
|
|
435
|
+
freed = 0
|
|
436
|
+
removed = []
|
|
437
|
+
for root, _, files in os.walk(profile_path):
|
|
438
|
+
if os.path.basename(root) != "log":
|
|
439
|
+
continue
|
|
440
|
+
for filename in files:
|
|
441
|
+
if not filename.endswith(".log"):
|
|
442
|
+
continue
|
|
443
|
+
path = os.path.join(root, filename)
|
|
444
|
+
try:
|
|
445
|
+
stat = os.stat(path)
|
|
446
|
+
except OSError:
|
|
447
|
+
continue
|
|
448
|
+
if stat.st_mtime >= cutoff:
|
|
449
|
+
continue
|
|
450
|
+
try:
|
|
451
|
+
os.remove(path)
|
|
452
|
+
except OSError as error:
|
|
453
|
+
raise CdxError(f"Failed to remove old log {path}: {error}") from error
|
|
454
|
+
freed += stat.st_size
|
|
455
|
+
removed.append(path)
|
|
456
|
+
return freed, removed
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _handle_clean_profiles(args, ctx, json_flag):
|
|
460
|
+
usage = "Usage: cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]"
|
|
461
|
+
yes = "--yes" in args
|
|
462
|
+
args = [arg for arg in args if arg != "--yes"]
|
|
463
|
+
clean_tmp = "--tmp" in args
|
|
464
|
+
old_logs = None
|
|
465
|
+
if "--old-logs" in args:
|
|
466
|
+
index = args.index("--old-logs")
|
|
467
|
+
if index + 1 >= len(args):
|
|
468
|
+
raise CdxError(usage)
|
|
469
|
+
old_logs = _parse_days(args[index + 1], usage)
|
|
470
|
+
args = [arg for i, arg in enumerate(args) if i not in (index, index + 1)]
|
|
471
|
+
args = [arg for arg in args if arg != "--tmp"]
|
|
472
|
+
if args or clean_tmp == (old_logs is not None):
|
|
473
|
+
raise CdxError(usage)
|
|
474
|
+
|
|
475
|
+
action = "temporary cache" if clean_tmp else f"logs older than {old_logs}d"
|
|
476
|
+
if not yes:
|
|
477
|
+
confirm_fn = ctx["options"].get("confirmProfileCleanup")
|
|
478
|
+
if confirm_fn:
|
|
479
|
+
confirmed = _resolve_confirmation(confirm_fn, action)
|
|
480
|
+
elif not ctx["stdin_is_tty"]:
|
|
481
|
+
raise CdxError("Profile cleanup requires an interactive terminal or --yes in non-interactive mode.")
|
|
482
|
+
else:
|
|
483
|
+
confirmed = _confirm_profile_cleanup(action)
|
|
484
|
+
if not confirmed:
|
|
485
|
+
if json_flag:
|
|
486
|
+
_write_json(ctx, _json_success("clean.profiles", "Cancelled.", cancelled=True, freed_bytes=0, freed_size="0 B", profiles=[]))
|
|
487
|
+
return 0
|
|
488
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
489
|
+
return 0
|
|
490
|
+
|
|
491
|
+
results = []
|
|
492
|
+
now = ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None
|
|
493
|
+
for profile_name, profile_path in _iter_profile_dirs(ctx["service"]["base_dir"]):
|
|
494
|
+
if clean_tmp:
|
|
495
|
+
freed, removed = _clean_profile_tmp(profile_path)
|
|
496
|
+
result_action = "tmp"
|
|
497
|
+
else:
|
|
498
|
+
freed, removed = _clean_profile_old_logs(profile_path, old_logs, now=now)
|
|
499
|
+
result_action = f"old-logs-{old_logs}d"
|
|
500
|
+
if not freed and not removed:
|
|
501
|
+
continue
|
|
502
|
+
results.append({
|
|
503
|
+
"profile": profile_name,
|
|
504
|
+
"action": result_action,
|
|
505
|
+
"freed_bytes": freed,
|
|
506
|
+
"freed_size": _format_bytes(freed),
|
|
507
|
+
"removed_count": len(removed),
|
|
508
|
+
"removed_paths": removed,
|
|
509
|
+
})
|
|
510
|
+
total = sum(item["freed_bytes"] for item in results)
|
|
511
|
+
if json_flag:
|
|
512
|
+
_write_json(ctx, _json_success("clean.profiles", f"Cleaned profiles ({_format_bytes(total)} freed)", cancelled=False, freed_bytes=total, freed_size=_format_bytes(total), profiles=results))
|
|
513
|
+
return 0
|
|
514
|
+
if not results:
|
|
515
|
+
ctx["out"](f"{_dim('No matching profile cleanup candidates found.', ctx['use_color'])}\n")
|
|
516
|
+
return 0
|
|
517
|
+
for item in results:
|
|
518
|
+
message = f"Cleaned {item['profile']} {item['action']} ({item['freed_size']} freed)"
|
|
519
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
520
|
+
ctx["out"](f"{_success(f'Total freed: {_format_bytes(total)}', ctx['use_color'])}\n")
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def handle_disk(rest, ctx):
|
|
525
|
+
parsed = _parse_flag_args(rest, {
|
|
526
|
+
"--candidates": {"key": "candidates", "type": "bool", "default": False},
|
|
527
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
528
|
+
}, DISK_USAGE, positionals_key="targets", max_positionals=1)
|
|
529
|
+
target = parsed["targets"][0] if parsed["targets"] else "home"
|
|
530
|
+
if target == "home":
|
|
531
|
+
path = ctx["service"]["base_dir"]
|
|
532
|
+
elif target == "profiles":
|
|
533
|
+
path = os.path.join(ctx["service"]["base_dir"], "profiles")
|
|
534
|
+
else:
|
|
535
|
+
raise CdxError(DISK_USAGE)
|
|
536
|
+
stderr = ctx["options"].get("stderr", sys.stderr)
|
|
537
|
+
show_progress = not parsed["json"] and hasattr(stderr, "isatty") and stderr.isatty()
|
|
538
|
+
|
|
539
|
+
def progress(stage, name=None, index=None, total=None):
|
|
540
|
+
if not show_progress:
|
|
541
|
+
return
|
|
542
|
+
if stage == "total":
|
|
543
|
+
label = "CDX profiles" if target == "profiles" else "CDX home"
|
|
544
|
+
ctx["err"](f"Measuring {label} disk usage...\n")
|
|
545
|
+
elif stage == "profile":
|
|
546
|
+
ctx["err"](f"Measuring profile {name} ({index}/{total})...\n")
|
|
547
|
+
elif stage == "candidates":
|
|
548
|
+
ctx["err"](f"Scanning cleanup candidates in {name} ({index}/{total})...\n")
|
|
549
|
+
|
|
550
|
+
progress("total")
|
|
551
|
+
bytes_used = _directory_size_bytes(path, runner=ctx["options"].get("diskUsageRunner"))
|
|
552
|
+
payload = {
|
|
553
|
+
"target": target,
|
|
554
|
+
"path": path,
|
|
555
|
+
"bytes": bytes_used,
|
|
556
|
+
"size": _format_bytes(bytes_used),
|
|
557
|
+
}
|
|
558
|
+
if target == "profiles":
|
|
559
|
+
payload["children"] = _directory_child_sizes(path, runner=ctx["options"].get("diskUsageRunner"), progress=progress)
|
|
560
|
+
if parsed["candidates"]:
|
|
561
|
+
if target != "profiles":
|
|
562
|
+
raise CdxError(DISK_USAGE)
|
|
563
|
+
candidates = _collect_profile_cleanup_candidates(
|
|
564
|
+
ctx["service"]["base_dir"],
|
|
565
|
+
runner=ctx["options"].get("diskUsageRunner"),
|
|
566
|
+
now=ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None,
|
|
567
|
+
progress=progress,
|
|
568
|
+
)
|
|
569
|
+
payload["candidates"] = candidates
|
|
570
|
+
payload["reclaimable_bytes"] = sum(item["bytes"] for item in candidates)
|
|
571
|
+
payload["reclaimable_size"] = _format_bytes(payload["reclaimable_bytes"])
|
|
572
|
+
if parsed["json"]:
|
|
573
|
+
label = "CDX profiles" if target == "profiles" else "CDX home"
|
|
574
|
+
_write_json(ctx, _json_success("disk", f"Measured {label} disk usage: {payload['size']}", disk=payload))
|
|
575
|
+
return 0
|
|
576
|
+
ctx["out"](f"{_format_disk_report(payload, use_color=ctx['use_color'])}\n")
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
|
|
154
580
|
def _extract_claude_oauth_token(text):
|
|
155
581
|
if not text:
|
|
156
582
|
return None
|
|
@@ -1309,6 +1735,10 @@ def handle_last(rest, ctx):
|
|
|
1309
1735
|
|
|
1310
1736
|
def handle_clean(rest, ctx):
|
|
1311
1737
|
json_flag, args = _parse_json_flag(rest)
|
|
1738
|
+
if args[:1] == ["profiles"] and any(arg in ("--tmp", "--old-logs") for arg in args[1:]):
|
|
1739
|
+
return _handle_clean_profiles(args[1:], ctx, json_flag)
|
|
1740
|
+
yes = "--yes" in args
|
|
1741
|
+
args = [arg for arg in args if arg != "--yes"]
|
|
1312
1742
|
service = ctx["service"]
|
|
1313
1743
|
if len(args) == 0:
|
|
1314
1744
|
targets = service["list_sessions"]()
|
|
@@ -1318,7 +1748,23 @@ def handle_clean(rest, ctx):
|
|
|
1318
1748
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
1319
1749
|
targets = [session]
|
|
1320
1750
|
else:
|
|
1321
|
-
raise CdxError("Usage: cdx clean [name] [--json]")
|
|
1751
|
+
raise CdxError("Usage: cdx clean [name] [--yes] [--json]")
|
|
1752
|
+
|
|
1753
|
+
if not yes:
|
|
1754
|
+
target = targets[0]["name"] if len(targets) == 1 else f"all {len(targets)} sessions"
|
|
1755
|
+
confirm_fn = ctx["options"].get("confirmClean")
|
|
1756
|
+
if confirm_fn:
|
|
1757
|
+
confirmed = _resolve_confirmation(confirm_fn, target)
|
|
1758
|
+
elif not ctx["stdin_is_tty"]:
|
|
1759
|
+
raise CdxError("Log cleanup requires an interactive terminal or --yes in non-interactive mode.")
|
|
1760
|
+
else:
|
|
1761
|
+
confirmed = _confirm_log_cleanup(target)
|
|
1762
|
+
if not confirmed:
|
|
1763
|
+
if json_flag:
|
|
1764
|
+
_write_json(ctx, _json_success("clean", "Cancelled.", cancelled=True, sessions=[]))
|
|
1765
|
+
return 0
|
|
1766
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
1767
|
+
return 0
|
|
1322
1768
|
|
|
1323
1769
|
cleaned_sessions = []
|
|
1324
1770
|
for session in targets:
|
|
@@ -1373,7 +1819,7 @@ def handle_clean(rest, ctx):
|
|
|
1373
1819
|
continue
|
|
1374
1820
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
1375
1821
|
if json_flag:
|
|
1376
|
-
_write_json(ctx, _json_success("clean", "Cleaned session logs", sessions=cleaned_sessions))
|
|
1822
|
+
_write_json(ctx, _json_success("clean", "Cleaned session logs", cancelled=False, sessions=cleaned_sessions))
|
|
1377
1823
|
return 0
|
|
1378
1824
|
|
|
1379
1825
|
|
|
@@ -1667,6 +2113,69 @@ def handle_status(rest, ctx):
|
|
|
1667
2113
|
return 0
|
|
1668
2114
|
|
|
1669
2115
|
|
|
2116
|
+
def handle_reset(rest, ctx):
|
|
2117
|
+
parsed = _parse_flag_args(rest, {
|
|
2118
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
2119
|
+
"--yes": {"key": "yes", "type": "bool", "default": False},
|
|
2120
|
+
}, "Usage: cdx reset <name> [--yes] [--json]", positionals_key="args", max_positionals=1)
|
|
2121
|
+
if len(parsed["args"]) != 1:
|
|
2122
|
+
raise CdxError("Usage: cdx reset <name> [--yes] [--json]")
|
|
2123
|
+
name = parsed["args"][0]
|
|
2124
|
+
session = ctx["service"]["get_session"](name)
|
|
2125
|
+
if not session:
|
|
2126
|
+
raise CdxError(f"Unknown session: {name}")
|
|
2127
|
+
if session["provider"] != PROVIDER_CODEX:
|
|
2128
|
+
raise CdxError("Banked rate-limit resets are only available for Codex sessions.")
|
|
2129
|
+
|
|
2130
|
+
status = ctx["service"]["get_status_row"](name, force_refresh=True)
|
|
2131
|
+
if status.get("reset_credits_available") == 0:
|
|
2132
|
+
raise CdxError(f"No banked Codex reset is available for {name}.")
|
|
2133
|
+
if not parsed["yes"]:
|
|
2134
|
+
confirm_fn = ctx["options"].get("confirmReset")
|
|
2135
|
+
if confirm_fn:
|
|
2136
|
+
confirmed = _resolve_confirmation(confirm_fn, name)
|
|
2137
|
+
elif not ctx["stdin_is_tty"]:
|
|
2138
|
+
raise CdxError("Reset activation requires an interactive terminal or --yes in non-interactive mode.")
|
|
2139
|
+
else:
|
|
2140
|
+
confirmed = _confirm_reset(name)
|
|
2141
|
+
if not confirmed:
|
|
2142
|
+
if parsed["json"]:
|
|
2143
|
+
_write_json(ctx, _json_success("reset", "Cancelled.", cancelled=True, session=name))
|
|
2144
|
+
return 0
|
|
2145
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
2146
|
+
return 0
|
|
2147
|
+
|
|
2148
|
+
credit_rows = status.get("reset_credits") or []
|
|
2149
|
+
credit_id = credit_rows[0].get("id") if credit_rows else None
|
|
2150
|
+
consumer = ctx["options"].get("consumeCodexReset") or consume_codex_rate_limit_reset_credit
|
|
2151
|
+
result = consumer(
|
|
2152
|
+
session,
|
|
2153
|
+
ctx["options"].get("resetIdempotencyKey") or str(uuid.uuid4()),
|
|
2154
|
+
credit_id=credit_id,
|
|
2155
|
+
)
|
|
2156
|
+
if not result.get("ok"):
|
|
2157
|
+
if result.get("reason") == "reset_consume_failed":
|
|
2158
|
+
raise CdxError(
|
|
2159
|
+
f"Unable to activate Codex reset for {name}. "
|
|
2160
|
+
"The installed Codex version or account may not support reset activation."
|
|
2161
|
+
)
|
|
2162
|
+
raise CdxError(f"Unable to activate Codex reset for {name}: {result.get('reason') or 'unknown error'}")
|
|
2163
|
+
outcome = result.get("outcome")
|
|
2164
|
+
if outcome == "noCredit":
|
|
2165
|
+
raise CdxError(f"No banked Codex reset is available for {name}.")
|
|
2166
|
+
if outcome == "nothingToReset":
|
|
2167
|
+
raise CdxError(f"No current Codex rate-limit window is eligible for reset on {name}.")
|
|
2168
|
+
if outcome not in ("reset", "alreadyRedeemed"):
|
|
2169
|
+
raise CdxError(f"Unexpected Codex reset outcome for {name}: {outcome or 'missing'}")
|
|
2170
|
+
refreshed = ctx["service"]["get_status_row"](name, force_refresh=True)
|
|
2171
|
+
message = f"Activated banked Codex reset for {name}"
|
|
2172
|
+
if parsed["json"]:
|
|
2173
|
+
_write_json(ctx, _json_success("reset", message, cancelled=False, outcome=outcome, session=refreshed))
|
|
2174
|
+
return 0
|
|
2175
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
2176
|
+
return 0
|
|
2177
|
+
|
|
2178
|
+
|
|
1670
2179
|
def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
|
|
1671
2180
|
target_names = set(target_names or [])
|
|
1672
2181
|
errors = []
|
package/src/cli_helpers.py
CHANGED
|
@@ -68,6 +68,13 @@ def _update_notice_warning(ctx):
|
|
|
68
68
|
def _update_notice_warnings(ctx):
|
|
69
69
|
warnings = []
|
|
70
70
|
for notice in ctx.get("update_notices") or []:
|
|
71
|
+
if notice.get("code") == "disk_cleanup_available":
|
|
72
|
+
warnings.append({
|
|
73
|
+
"code": "disk_cleanup_available",
|
|
74
|
+
"message": notice["message"],
|
|
75
|
+
**{key: value for key, value in notice.items() if key not in ("code", "message")},
|
|
76
|
+
})
|
|
77
|
+
continue
|
|
71
78
|
tool = notice.get("tool") or "cdx-manager"
|
|
72
79
|
current = notice.get("current_version") or ctx.get("version")
|
|
73
80
|
command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
|
package/src/codex_usage.py
CHANGED
|
@@ -65,6 +65,15 @@ def _format_reset_date(unix_seconds):
|
|
|
65
65
|
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _format_timestamp(unix_seconds):
|
|
69
|
+
if unix_seconds is None:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
return datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc).astimezone().isoformat()
|
|
73
|
+
except (TypeError, ValueError, OSError):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
68
77
|
def _remaining_from_used_percent(value):
|
|
69
78
|
if value is None:
|
|
70
79
|
return None
|
|
@@ -84,7 +93,7 @@ def _get_window(snapshot, duration_mins):
|
|
|
84
93
|
return {}
|
|
85
94
|
|
|
86
95
|
|
|
87
|
-
def normalize_codex_rate_limit_snapshot(snapshot):
|
|
96
|
+
def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
|
|
88
97
|
if not snapshot:
|
|
89
98
|
return None
|
|
90
99
|
|
|
@@ -101,6 +110,23 @@ def normalize_codex_rate_limit_snapshot(snapshot):
|
|
|
101
110
|
|
|
102
111
|
reset_5h_at = _format_reset_date(five_hour.get("resetsAt") or five_hour.get("resets_at"))
|
|
103
112
|
reset_week_at = _format_reset_date(weekly.get("resetsAt") or weekly.get("resets_at"))
|
|
113
|
+
reset_credits = reset_credits or {}
|
|
114
|
+
available_count = reset_credits.get("availableCount", reset_credits.get("available_count"))
|
|
115
|
+
try:
|
|
116
|
+
available_count = max(0, int(available_count)) if available_count is not None else None
|
|
117
|
+
except (TypeError, ValueError):
|
|
118
|
+
available_count = None
|
|
119
|
+
credit_rows = []
|
|
120
|
+
for credit in reset_credits.get("credits") or []:
|
|
121
|
+
if not isinstance(credit, dict) or credit.get("status") not in (None, "available"):
|
|
122
|
+
continue
|
|
123
|
+
credit_rows.append({
|
|
124
|
+
"id": credit.get("id"),
|
|
125
|
+
"title": credit.get("title"),
|
|
126
|
+
"description": credit.get("description"),
|
|
127
|
+
"granted_at": _format_timestamp(credit.get("grantedAt", credit.get("granted_at"))),
|
|
128
|
+
"expires_at": _format_timestamp(credit.get("expiresAt", credit.get("expires_at"))),
|
|
129
|
+
})
|
|
104
130
|
|
|
105
131
|
raw_status_text = json.dumps(snapshot, sort_keys=True)
|
|
106
132
|
return {
|
|
@@ -111,6 +137,8 @@ def normalize_codex_rate_limit_snapshot(snapshot):
|
|
|
111
137
|
weekly.get("usedPercent", weekly.get("used_percent"))
|
|
112
138
|
),
|
|
113
139
|
"credits": credit_balance,
|
|
140
|
+
"reset_credits_available": available_count,
|
|
141
|
+
"reset_credits": credit_rows,
|
|
114
142
|
"reset_5h_at": reset_5h_at,
|
|
115
143
|
"reset_week_at": reset_week_at,
|
|
116
144
|
"reset_at": reset_week_at or reset_5h_at,
|
|
@@ -166,6 +194,32 @@ def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
|
|
|
166
194
|
|
|
167
195
|
|
|
168
196
|
def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_factory=None):
|
|
197
|
+
diagnostic = _request_codex_app_server(
|
|
198
|
+
auth_home,
|
|
199
|
+
"account/rateLimits/read",
|
|
200
|
+
None,
|
|
201
|
+
"rate_limits_read_failed",
|
|
202
|
+
timeout=timeout,
|
|
203
|
+
popen_factory=popen_factory,
|
|
204
|
+
)
|
|
205
|
+
if not diagnostic["ok"]:
|
|
206
|
+
return diagnostic
|
|
207
|
+
response = diagnostic["response"]
|
|
208
|
+
result = response.get("result") or {}
|
|
209
|
+
by_limit = result.get("rateLimitsByLimitId") or {}
|
|
210
|
+
snapshot = by_limit.get("codex") or result.get("rateLimits")
|
|
211
|
+
status = normalize_codex_rate_limit_snapshot(snapshot, result.get("rateLimitResetCredits"))
|
|
212
|
+
if not status:
|
|
213
|
+
return {
|
|
214
|
+
"ok": False,
|
|
215
|
+
"reason": "missing_rate_limits",
|
|
216
|
+
"status": None,
|
|
217
|
+
"response": response,
|
|
218
|
+
}
|
|
219
|
+
return {"ok": True, "reason": None, "status": status, "response": response}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _request_codex_app_server(auth_home, method, params, failure_reason, timeout=5, popen_factory=None):
|
|
169
223
|
env = os.environ.copy()
|
|
170
224
|
env["CODEX_HOME"] = auth_home
|
|
171
225
|
popen_factory = popen_factory or subprocess.Popen
|
|
@@ -204,29 +258,18 @@ def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_fact
|
|
|
204
258
|
_write_json_line(process, {
|
|
205
259
|
"jsonrpc": "2.0",
|
|
206
260
|
"id": 2,
|
|
207
|
-
"method":
|
|
208
|
-
"params":
|
|
261
|
+
"method": method,
|
|
262
|
+
"params": params,
|
|
209
263
|
})
|
|
210
264
|
response = _read_response(output, 2, timeout)
|
|
211
265
|
if not response or response.get("error"):
|
|
212
266
|
return {
|
|
213
267
|
"ok": False,
|
|
214
|
-
"reason":
|
|
215
|
-
"status": None,
|
|
216
|
-
"response": response,
|
|
217
|
-
}
|
|
218
|
-
result = response.get("result") or {}
|
|
219
|
-
by_limit = result.get("rateLimitsByLimitId") or {}
|
|
220
|
-
snapshot = by_limit.get("codex") or result.get("rateLimits")
|
|
221
|
-
status = normalize_codex_rate_limit_snapshot(snapshot)
|
|
222
|
-
if not status:
|
|
223
|
-
return {
|
|
224
|
-
"ok": False,
|
|
225
|
-
"reason": "missing_rate_limits",
|
|
268
|
+
"reason": failure_reason,
|
|
226
269
|
"status": None,
|
|
227
270
|
"response": response,
|
|
228
271
|
}
|
|
229
|
-
return {"ok": True, "reason": None, "status":
|
|
272
|
+
return {"ok": True, "reason": None, "status": None, "response": response}
|
|
230
273
|
except FileNotFoundError as error:
|
|
231
274
|
return {"ok": False, "reason": "codex_cli_not_found", "status": None, "error": str(error)}
|
|
232
275
|
except (OSError, ValueError, BrokenPipeError) as error:
|
|
@@ -250,3 +293,25 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
|
250
293
|
popen_factory=popen_factory,
|
|
251
294
|
)
|
|
252
295
|
return diagnostic.get("status") if diagnostic.get("ok") else None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def consume_codex_rate_limit_reset_credit(session, idempotency_key, credit_id=None, timeout=5, popen_factory=None):
|
|
299
|
+
auth_home = session.get("authHome")
|
|
300
|
+
if not auth_home:
|
|
301
|
+
return {"ok": False, "reason": "missing_auth_home", "outcome": None}
|
|
302
|
+
with codex_auth_lock(auth_home) as acquired:
|
|
303
|
+
if not acquired:
|
|
304
|
+
return {"ok": False, "reason": "auth_locked", "outcome": None}
|
|
305
|
+
params = {"idempotencyKey": idempotency_key}
|
|
306
|
+
if credit_id:
|
|
307
|
+
params["creditId"] = credit_id
|
|
308
|
+
diagnostic = _request_codex_app_server(
|
|
309
|
+
auth_home,
|
|
310
|
+
"account/rateLimitResetCredit/consume",
|
|
311
|
+
params,
|
|
312
|
+
"reset_consume_failed",
|
|
313
|
+
timeout=timeout,
|
|
314
|
+
popen_factory=popen_factory,
|
|
315
|
+
)
|
|
316
|
+
result = (diagnostic.get("response") or {}).get("result") or {}
|
|
317
|
+
return {**diagnostic, "outcome": result.get("outcome")}
|
package/src/session_service.py
CHANGED
|
@@ -29,6 +29,7 @@ RESERVED_SESSION_NAMES = {
|
|
|
29
29
|
"configs",
|
|
30
30
|
"cp",
|
|
31
31
|
"disable",
|
|
32
|
+
"disk",
|
|
32
33
|
"doctor",
|
|
33
34
|
"enable",
|
|
34
35
|
"export",
|
|
@@ -48,6 +49,7 @@ RESERVED_SESSION_NAMES = {
|
|
|
48
49
|
"power",
|
|
49
50
|
"ready",
|
|
50
51
|
"repair",
|
|
52
|
+
"reset",
|
|
51
53
|
"resume",
|
|
52
54
|
"ren",
|
|
53
55
|
"rename",
|
|
@@ -260,6 +262,8 @@ def _normalize_status_payload(payload=None):
|
|
|
260
262
|
"remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
|
|
261
263
|
"remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
|
|
262
264
|
"credits": payload.get("credits"),
|
|
265
|
+
"reset_credits_available": payload.get("reset_credits_available"),
|
|
266
|
+
"reset_credits": payload.get("reset_credits"),
|
|
263
267
|
"reset_5h_at": payload.get("reset_5h_at"),
|
|
264
268
|
"reset_week_at": payload.get("reset_week_at"),
|
|
265
269
|
"reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
|
|
@@ -337,6 +341,8 @@ def _status_has_more_detail(candidate, current):
|
|
|
337
341
|
"remaining_5h_pct",
|
|
338
342
|
"remaining_week_pct",
|
|
339
343
|
"credits",
|
|
344
|
+
"reset_credits_available",
|
|
345
|
+
"reset_credits",
|
|
340
346
|
"reset_5h_at",
|
|
341
347
|
"reset_week_at",
|
|
342
348
|
"reset_at",
|
|
@@ -358,6 +364,8 @@ def _merge_status_payload(current, candidate):
|
|
|
358
364
|
"remaining_5h_pct",
|
|
359
365
|
"remaining_week_pct",
|
|
360
366
|
"credits",
|
|
367
|
+
"reset_credits_available",
|
|
368
|
+
"reset_credits",
|
|
361
369
|
"reset_5h_at",
|
|
362
370
|
"reset_week_at",
|
|
363
371
|
"reset_at",
|
|
@@ -1101,6 +1109,8 @@ def create_session_service(options=None):
|
|
|
1101
1109
|
"remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
|
|
1102
1110
|
"remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
|
|
1103
1111
|
"credits": row_status.get("credits") if row_status else None,
|
|
1112
|
+
"reset_credits_available": row_status.get("reset_credits_available") if row_status else None,
|
|
1113
|
+
"reset_credits": row_status.get("reset_credits") if row_status else None,
|
|
1104
1114
|
"available_pct": _compute_available_pct(row_status),
|
|
1105
1115
|
"reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
|
|
1106
1116
|
"reset_week_at": row_status.get("reset_week_at") if row_status else None,
|
package/src/status_view.py
CHANGED
|
@@ -76,15 +76,15 @@ def _format_credits(value, empty="n/a"):
|
|
|
76
76
|
def _format_status_rows(rows, use_color=False, small=False):
|
|
77
77
|
has_provider = len({r["provider"] for r in rows}) > 1 and not small
|
|
78
78
|
if small:
|
|
79
|
-
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
|
|
79
|
+
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESETS", "RESET 5H", "RESET WEEK"]
|
|
80
80
|
elif has_provider:
|
|
81
|
-
headers = ["SESSION", "PROV.", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
81
|
+
headers = ["SESSION", "PROV.", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
82
82
|
else:
|
|
83
|
-
headers = ["SESSION", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
83
|
+
headers = ["SESSION", "STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
84
84
|
if not rows:
|
|
85
85
|
if small:
|
|
86
|
-
return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
87
|
-
return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
|
|
86
|
+
return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
87
|
+
return "SESSION STATUS OK 5H WEEK BLOCK CR RESETS RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
|
|
88
88
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
89
89
|
active_rows = [r for r in rows if r.get("enabled", True) is not False]
|
|
90
90
|
priority = recommend_priority_rows(rows)
|
|
@@ -109,12 +109,13 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
109
109
|
auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
|
|
110
110
|
base.append(_style(auth, auth_color, use_color))
|
|
111
111
|
if r.get("enabled", True) is False:
|
|
112
|
-
usage_columns = [_style("-", "2", use_color)] *
|
|
112
|
+
usage_columns = [_style("-", "2", use_color)] * 6
|
|
113
113
|
else:
|
|
114
114
|
usage_columns = [
|
|
115
115
|
_style_pct(r.get("available_pct"), use_color),
|
|
116
116
|
_style_pct(r.get("remaining_5h_pct"), use_color),
|
|
117
117
|
_style_pct(r.get("remaining_week_pct"), use_color),
|
|
118
|
+
_style(str(r["reset_credits_available"]), "33", use_color) if r.get("reset_credits_available") is not None else _style("-", "2", use_color),
|
|
118
119
|
_style_reset_time(r.get("reset_5h_at"), use_color),
|
|
119
120
|
_style_reset_time(r.get("reset_week_at"), use_color),
|
|
120
121
|
]
|
|
@@ -367,8 +368,15 @@ def _format_status_detail(row, use_color=False):
|
|
|
367
368
|
f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
|
|
368
369
|
f"{_style('Block:', '1', use_color)} {_style(_format_blocking_quota(row), '33', use_color)}",
|
|
369
370
|
f"{_style('Credits:', '1', use_color)} {_style(_format_credits(row.get('credits')), '33' if row.get('credits') is not None else '2', use_color)}",
|
|
371
|
+
f"{_style('Bonus resets:', '1', use_color)} {_style(str(row['reset_credits_available']), '33', use_color) if row.get('reset_credits_available') is not None else _style('-', '2', use_color)}",
|
|
372
|
+
f"{_style('Reset expiry:', '1', use_color)} {_style_reset_time(_nearest_reset_expiry(row), use_color)}",
|
|
370
373
|
f"{_style('5h reset:', '1', use_color)} {_style_reset_time(row.get('reset_5h_at'), use_color)}",
|
|
371
374
|
f"{_style('Week reset:', '1', use_color)} {_style_reset_time(row.get('reset_week_at'), use_color)}",
|
|
372
375
|
f"{_style('Updated:', '1', use_color)} {_dim(_format_relative_age(row.get('updated_at')), use_color)}",
|
|
373
376
|
]
|
|
374
377
|
return "\n".join(lines)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _nearest_reset_expiry(row):
|
|
381
|
+
values = [credit.get("expires_at") for credit in row.get("reset_credits") or [] if credit.get("expires_at")]
|
|
382
|
+
return min(values, key=lambda value: _parse_reset_timestamp(value) or float("inf")) if values else None
|