cdx-manager 0.5.4 → 0.5.6
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 +6 -6
- package/changelogs/CHANGELOGS_0_5_5.md +48 -0
- package/changelogs/CHANGELOGS_0_5_6.md +42 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +33 -1
- package/src/cli.py +3 -3
- package/src/cli_commands.py +97 -4
- package/src/notify.py +50 -17
- package/src/session_service.py +131 -29
- package/src/status_view.py +33 -12
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -35,7 +35,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
35
35
|
- **Multiple accounts, one tool.** Register as many Codex or Claude sessions as you need. Each one gets its own isolated auth environment — no cross-contamination between accounts.
|
|
36
36
|
- **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
|
|
37
37
|
- **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
|
|
38
|
-
- **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota,
|
|
38
|
+
- **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, last-updated timestamps, priority guidance, and the last launched session in one aligned table.
|
|
39
39
|
- **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
|
|
40
40
|
- **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.
|
|
41
41
|
- **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.
|
|
@@ -126,7 +126,7 @@ For a specific version:
|
|
|
126
126
|
|
|
127
127
|
```bash
|
|
128
128
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
129
|
-
CDX_VERSION=v0.5.
|
|
129
|
+
CDX_VERSION=v0.5.6 sh install.sh
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
From source:
|
|
@@ -258,8 +258,8 @@ cdx status
|
|
|
258
258
|
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
259
259
|
| `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
|
|
260
260
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
261
|
-
| `cdx notify <name> --at-reset [--poll seconds] [--once] [--json]` | Wait for a session reset time and send a desktop notification when due |
|
|
262
|
-
| `cdx notify --next-ready [--poll seconds] [--once] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
|
|
261
|
+
| `cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] [--json]` | Wait for a session reset time and send a desktop notification when due |
|
|
262
|
+
| `cdx notify --next-ready [--poll seconds] [--once] [--refresh] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
|
|
263
263
|
| `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
|
|
264
264
|
| `cdx status --small [--refresh]` / `cdx status -s [--refresh]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
|
|
265
265
|
| `cdx status <name> [--json] [--refresh]` | Show detailed usage breakdown for one session |
|
|
@@ -349,7 +349,7 @@ cdx import backup-auth.cdx --passphrase-env CDX_BUNDLE_PASSPHRASE
|
|
|
349
349
|
|
|
350
350
|
Notes:
|
|
351
351
|
|
|
352
|
-
- `--include-auth` is encrypted
|
|
352
|
+
- `--include-auth` is encrypted, requires a passphrase, and exports only provider credential files rather than full profile caches or logs.
|
|
353
353
|
- Without `--passphrase-env`, `cdx` prompts in an interactive terminal.
|
|
354
354
|
- `--sessions work,perso` exports or imports only a subset.
|
|
355
355
|
- `--force` allows overwriting existing destination sessions during import or replacing an existing bundle file during export.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog (`0.5.4 -> 0.5.5`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-24
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Fixed Claude usage probing for OAuth credentials by using bearer-token authentication.
|
|
8
|
+
- Improved `cdx status` guidance with progress output, conservative reset timing, near-empty account handling, and last-launched session context.
|
|
9
|
+
- Made encrypted auth exports lightweight by backing up provider credential files instead of full profile caches, logs, and runtime artifacts.
|
|
10
|
+
|
|
11
|
+
## Usage and Status
|
|
12
|
+
|
|
13
|
+
- Added progress output while `cdx status` resolves session statuses in text mode, while keeping `--json` output clean.
|
|
14
|
+
- Added a `Current:` line under the priority recommendation showing the last launched session, useful before running a handoff.
|
|
15
|
+
- Removed the older status tip line now that status output carries actionable priority and current-session context.
|
|
16
|
+
- Treated sessions with `5%` or less availability as empty for priority recommendations, while preserving the true percentage in the table.
|
|
17
|
+
- Added a conservative one-minute reset countdown margin so users are less likely to switch back before a reset is usable.
|
|
18
|
+
|
|
19
|
+
## Claude Usage Handling
|
|
20
|
+
|
|
21
|
+
- Fixed the Claude rate-limit probe to send OAuth access tokens as `Authorization: Bearer ...`.
|
|
22
|
+
- Surfaced Claude HTTP failures without rate-limit headers as structured refresh warnings, including API error text when available.
|
|
23
|
+
- Preserved existing fallback behavior for unavailable network probes and historical status data.
|
|
24
|
+
|
|
25
|
+
## Export and Backup
|
|
26
|
+
|
|
27
|
+
- Limited `cdx export --include-auth` to provider credential files:
|
|
28
|
+
- Codex: `auth.json`
|
|
29
|
+
- Claude: `claude-home/.claude/.credentials.json`, `claude-home/.claude.json`, and legacy `claude-home/auth.json`
|
|
30
|
+
- Excluded profile caches, logs, SQLite usage databases, temporary files, and provider runtime artifacts from auth bundles.
|
|
31
|
+
- Added export progress output in text mode and a final summary with bundle size, auth file count, and auth data size.
|
|
32
|
+
- Kept `cdx export --json` clean and added bundle metrics to its JSON payload.
|
|
33
|
+
|
|
34
|
+
## Documentation
|
|
35
|
+
|
|
36
|
+
- Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.5`.
|
|
37
|
+
- Clarified that encrypted auth exports include credential files rather than full profile caches or logs.
|
|
38
|
+
|
|
39
|
+
## Validation and Regression Coverage
|
|
40
|
+
|
|
41
|
+
- Added regression coverage for Claude HTTP failures without usage headers.
|
|
42
|
+
- Added status rendering tests for progress output, last-launched context, near-empty priority handling, and removed tip text.
|
|
43
|
+
- Added export/import tests proving auth bundles exclude non-auth profile files such as `logs_2.sqlite`.
|
|
44
|
+
|
|
45
|
+
## Validation and Regression Evidence
|
|
46
|
+
|
|
47
|
+
- `python3 -m pytest test/test_cli_py.py test/test_session_service_py.py`
|
|
48
|
+
- `python3 -m pytest test/test_runtime_py.py test/test_cli_py.py test/test_status_source_py.py`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog (`0.5.5 -> 0.5.6`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-24
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Reduced noisy `cdx status` progress output by respecting fresh cached status rows.
|
|
8
|
+
- Stopped probing disabled sessions during status resolution and hid stale quota metrics for disabled rows.
|
|
9
|
+
- Refined `cdx notify --next-ready` so it waits for the next blocked account reset instead of notifying for an account that is already usable.
|
|
10
|
+
|
|
11
|
+
## Status Output
|
|
12
|
+
|
|
13
|
+
- Added status-row cache handling so recently resolved rows do not print redundant `Checking ...` progress lines.
|
|
14
|
+
- Kept Claude status cache behavior aligned with the Claude refresh TTL, avoiding unnecessary Claude checks while cached usage is still fresh.
|
|
15
|
+
- Treated disabled sessions as display-only in `cdx status`; disabled sessions remain visible but are not refreshed or re-read from status artifacts.
|
|
16
|
+
- Rendered disabled-session quota columns as `-` for `OK`, `5H`, `WEEK`, `BLOCK`, `CR`, `RESET 5H`, and `RESET WEEK`.
|
|
17
|
+
- Preserved the stored last status internally so re-enabled sessions can resume normal status behavior.
|
|
18
|
+
|
|
19
|
+
## Notifications
|
|
20
|
+
|
|
21
|
+
- Added `--refresh` support to `cdx notify` so notification checks can force status refreshes when requested.
|
|
22
|
+
- Added text-mode progress output for notification checks while keeping `--json` output clean.
|
|
23
|
+
- Made `cdx notify --next-ready` ignore disabled sessions.
|
|
24
|
+
- Made `cdx notify --next-ready` ignore sessions that are already usable and instead target the next currently blocked session with a known reset.
|
|
25
|
+
- Returned `No upcoming session reset available` when there is no blocked session with a reset to wait for.
|
|
26
|
+
|
|
27
|
+
## Documentation
|
|
28
|
+
|
|
29
|
+
- Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.6`.
|
|
30
|
+
- Documented `--refresh` for `cdx notify` command examples.
|
|
31
|
+
|
|
32
|
+
## Validation and Regression Coverage
|
|
33
|
+
|
|
34
|
+
- Added regression coverage for cached Codex status rows suppressing redundant checking progress.
|
|
35
|
+
- Added regression coverage for Claude cache TTL behavior.
|
|
36
|
+
- Added regression coverage for disabled sessions skipping refresh and hiding public status metrics.
|
|
37
|
+
- Added regression coverage for `notify --next-ready` ignoring already-available and disabled sessions.
|
|
38
|
+
|
|
39
|
+
## Validation and Regression Evidence
|
|
40
|
+
|
|
41
|
+
- `npm run test:py`
|
|
42
|
+
- `./bin/cdx status`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdx-manager",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "Terminal session manager for Codex and Claude accounts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexandre Agostini",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"bin",
|
|
28
|
+
"!bin/__pycache__",
|
|
29
|
+
"!bin/**/*.pyc",
|
|
28
30
|
"checksums",
|
|
29
31
|
"changelogs",
|
|
30
32
|
"src",
|
package/pyproject.toml
CHANGED
package/src/claude_usage.py
CHANGED
|
@@ -4,6 +4,8 @@ import urllib.request
|
|
|
4
4
|
import urllib.error
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
|
|
7
|
+
from .errors import CdxError
|
|
8
|
+
|
|
7
9
|
MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
8
10
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
9
11
|
CLAUDE_STATUS_PROBE_MODEL = os.environ.get("CDX_CLAUDE_STATUS_MODEL", "claude-haiku-4-5-20251001")
|
|
@@ -24,6 +26,29 @@ def _format_reset_date(unix_seconds):
|
|
|
24
26
|
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _read_http_error_message(error):
|
|
30
|
+
try:
|
|
31
|
+
body = error.read().decode("utf-8", errors="replace")
|
|
32
|
+
except (AttributeError, OSError, UnicodeDecodeError):
|
|
33
|
+
body = ""
|
|
34
|
+
if not body:
|
|
35
|
+
return ""
|
|
36
|
+
try:
|
|
37
|
+
payload = json.loads(body)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
return body.strip()
|
|
40
|
+
detail = payload.get("error") if isinstance(payload, dict) else None
|
|
41
|
+
if isinstance(detail, dict):
|
|
42
|
+
message = detail.get("message") or detail.get("type")
|
|
43
|
+
if message:
|
|
44
|
+
return str(message)
|
|
45
|
+
if isinstance(payload, dict):
|
|
46
|
+
message = payload.get("message") or payload.get("detail")
|
|
47
|
+
if message:
|
|
48
|
+
return str(message)
|
|
49
|
+
return body.strip()
|
|
50
|
+
|
|
51
|
+
|
|
27
52
|
def fetch_claude_rate_limit_headers(access_token):
|
|
28
53
|
body = json.dumps({
|
|
29
54
|
"model": CLAUDE_STATUS_PROBE_MODEL,
|
|
@@ -35,7 +60,7 @@ def fetch_claude_rate_limit_headers(access_token):
|
|
|
35
60
|
"https://api.anthropic.com/v1/messages",
|
|
36
61
|
data=body,
|
|
37
62
|
headers={
|
|
38
|
-
"
|
|
63
|
+
"Authorization": f"Bearer {access_token}",
|
|
39
64
|
"anthropic-version": "2023-06-01",
|
|
40
65
|
"content-type": "application/json",
|
|
41
66
|
},
|
|
@@ -47,6 +72,13 @@ def fetch_claude_rate_limit_headers(access_token):
|
|
|
47
72
|
headers = {k.lower(): v for k, v in resp.getheaders()}
|
|
48
73
|
except urllib.error.HTTPError as e:
|
|
49
74
|
headers = {k.lower(): v for k, v in e.headers.items()}
|
|
75
|
+
if (
|
|
76
|
+
"anthropic-ratelimit-unified-5h-utilization" not in headers
|
|
77
|
+
and "anthropic-ratelimit-unified-7d-utilization" not in headers
|
|
78
|
+
):
|
|
79
|
+
message = _read_http_error_message(e)
|
|
80
|
+
suffix = f": {message}" if message else ""
|
|
81
|
+
raise CdxError(f"Claude usage unavailable (HTTP {e.code}{suffix})") from e
|
|
50
82
|
except urllib.error.URLError:
|
|
51
83
|
return None
|
|
52
84
|
|
package/src/cli.py
CHANGED
|
@@ -49,7 +49,7 @@ from .status_view import (
|
|
|
49
49
|
)
|
|
50
50
|
from .update_check import check_for_update
|
|
51
51
|
|
|
52
|
-
VERSION = "0.5.
|
|
52
|
+
VERSION = "0.5.6"
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
# ---------------------------------------------------------------------------
|
|
@@ -83,8 +83,8 @@ def _print_help(use_color=False):
|
|
|
83
83
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
84
84
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
85
85
|
f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
86
|
-
f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
|
|
87
|
-
f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
|
|
86
|
+
f" {_style('cdx notify <name> --at-reset [--refresh] [--json]', '36', use_color)}",
|
|
87
|
+
f" {_style('cdx notify --next-ready [--refresh] [--json]', '36', use_color)}",
|
|
88
88
|
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
89
89
|
f" {_style('cdx --help', '36', use_color)}",
|
|
90
90
|
f" {_style('cdx --version', '36', use_color)}",
|
package/src/cli_commands.py
CHANGED
|
@@ -69,6 +69,95 @@ def _write_json(ctx, payload):
|
|
|
69
69
|
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def _format_bytes(value):
|
|
73
|
+
if value is None:
|
|
74
|
+
return "n/a"
|
|
75
|
+
try:
|
|
76
|
+
amount = float(value)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return str(value)
|
|
79
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
80
|
+
unit = units[0]
|
|
81
|
+
for unit in units:
|
|
82
|
+
if amount < 1024 or unit == units[-1]:
|
|
83
|
+
break
|
|
84
|
+
amount /= 1024
|
|
85
|
+
if unit == "B":
|
|
86
|
+
return f"{int(amount)} B"
|
|
87
|
+
return f"{amount:.1f} {unit}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_export_report(result):
|
|
91
|
+
lines = [
|
|
92
|
+
f"Path: {result['path']}",
|
|
93
|
+
f"Sessions: {', '.join(result['session_names']) or '-'}",
|
|
94
|
+
f"Auth: {'included and encrypted' if result['include_auth'] else 'not included'}",
|
|
95
|
+
f"Bundle size: {_format_bytes(result.get('bundle_size_bytes'))}",
|
|
96
|
+
]
|
|
97
|
+
if result.get("include_auth"):
|
|
98
|
+
lines.extend([
|
|
99
|
+
f"Auth files: {result.get('profile_file_count', 0)}",
|
|
100
|
+
f"Auth data: {_format_bytes(result.get('profile_bytes'))}",
|
|
101
|
+
])
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _make_export_progress(ctx):
|
|
106
|
+
def progress(event):
|
|
107
|
+
kind = event.get("event")
|
|
108
|
+
if kind == "export_started":
|
|
109
|
+
auth = " with auth" if event.get("include_auth") else ""
|
|
110
|
+
message = f"Exporting {event.get('session_count', 0)} session(s){auth}..."
|
|
111
|
+
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
112
|
+
elif kind == "session_started":
|
|
113
|
+
message = f"Collecting {event.get('session_name')}..."
|
|
114
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
115
|
+
elif kind == "profile_progress":
|
|
116
|
+
message = f" {event.get('session_name')}: {event.get('file_count', 0)} files, {_format_bytes(event.get('bytes'))}"
|
|
117
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
118
|
+
elif kind == "encoding_started":
|
|
119
|
+
ctx["out"](f"{_dim('Encoding and encrypting bundle...', ctx['use_color'])}\n")
|
|
120
|
+
elif kind == "writing_started":
|
|
121
|
+
message = f"Writing {_format_bytes(event.get('bundle_size_bytes'))}..."
|
|
122
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
123
|
+
return progress
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _make_status_progress(ctx):
|
|
127
|
+
def progress(event):
|
|
128
|
+
kind = event.get("event")
|
|
129
|
+
if kind == "status_started":
|
|
130
|
+
message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
|
|
131
|
+
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
132
|
+
elif kind == "session_started":
|
|
133
|
+
provider = event.get("provider") or "session"
|
|
134
|
+
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
135
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
136
|
+
elif kind == "status_finished":
|
|
137
|
+
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
138
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
139
|
+
return progress
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _make_notify_progress(ctx):
|
|
143
|
+
def progress(event):
|
|
144
|
+
kind = event.get("event")
|
|
145
|
+
if kind == "notify_check_started":
|
|
146
|
+
target = event.get("session_name") or "next ready session"
|
|
147
|
+
ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
|
|
148
|
+
elif kind == "status_started":
|
|
149
|
+
message = f"Loading status for {event.get('session_count', 0)} session(s)..."
|
|
150
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
151
|
+
elif kind == "session_started":
|
|
152
|
+
provider = event.get("provider") or "session"
|
|
153
|
+
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
154
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
155
|
+
elif kind == "notify_waiting":
|
|
156
|
+
message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
|
|
157
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
158
|
+
return progress
|
|
159
|
+
|
|
160
|
+
|
|
72
161
|
def _latest_launch_transcript_path(session):
|
|
73
162
|
paths = _list_launch_transcript_paths(session)
|
|
74
163
|
if not paths:
|
|
@@ -556,6 +645,7 @@ def handle_notify(rest, ctx):
|
|
|
556
645
|
notifier=notifier,
|
|
557
646
|
sleep_fn=ctx["options"].get("sleep"),
|
|
558
647
|
now_fn=ctx["options"].get("now"),
|
|
648
|
+
progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
|
|
559
649
|
)
|
|
560
650
|
if parsed["json"]:
|
|
561
651
|
_write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
|
|
@@ -696,7 +786,11 @@ def handle_status(rest, ctx):
|
|
|
696
786
|
for item in refresh_errors
|
|
697
787
|
]
|
|
698
788
|
|
|
699
|
-
|
|
789
|
+
status_progress = None if parsed["json"] else _make_status_progress(ctx)
|
|
790
|
+
rows = ctx["service"]["get_status_rows"](
|
|
791
|
+
progress_callback=status_progress,
|
|
792
|
+
force_refresh=parsed["refresh"],
|
|
793
|
+
)
|
|
700
794
|
if len(args) == 0:
|
|
701
795
|
if parsed["json"]:
|
|
702
796
|
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
|
|
@@ -740,6 +834,7 @@ def handle_export(rest, ctx):
|
|
|
740
834
|
session_names=parsed["session_names"],
|
|
741
835
|
passphrase=passphrase,
|
|
742
836
|
force=parsed["force"],
|
|
837
|
+
progress_callback=None if parsed["json"] else _make_export_progress(ctx),
|
|
743
838
|
)
|
|
744
839
|
session_count = len(result["session_names"])
|
|
745
840
|
auth_suffix = " with auth" if result["include_auth"] else ""
|
|
@@ -753,6 +848,7 @@ def handle_export(rest, ctx):
|
|
|
753
848
|
_write_json(ctx, payload)
|
|
754
849
|
return 0
|
|
755
850
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
851
|
+
ctx["out"](f"{_format_export_report(result)}\n")
|
|
756
852
|
return 0
|
|
757
853
|
|
|
758
854
|
|
|
@@ -961,9 +1057,6 @@ def handle_launch(command, ctx, initial_prompt=None):
|
|
|
961
1057
|
if update_notice.get("url"):
|
|
962
1058
|
text = f"{text} {update_notice['url']}"
|
|
963
1059
|
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
964
|
-
if session["provider"] == PROVIDER_CODEX:
|
|
965
|
-
if not json_flag:
|
|
966
|
-
ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
|
|
967
1060
|
_run_interactive_provider_command(
|
|
968
1061
|
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
969
1062
|
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
package/src/notify.py
CHANGED
|
@@ -4,7 +4,12 @@ import subprocess
|
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
6
|
from .errors import CdxError
|
|
7
|
-
from .status_view import
|
|
7
|
+
from .status_view import (
|
|
8
|
+
PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
|
|
9
|
+
_parse_reset_timestamp,
|
|
10
|
+
_priority_reset_timestamp,
|
|
11
|
+
_recommend_priority_sessions,
|
|
12
|
+
)
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
def parse_notify_args(args):
|
|
@@ -12,17 +17,18 @@ def parse_notify_args(args):
|
|
|
12
17
|
once = "--once" in args
|
|
13
18
|
at_reset = "--at-reset" in args
|
|
14
19
|
next_ready = "--next-ready" in args
|
|
20
|
+
refresh = "--refresh" in args
|
|
15
21
|
poll = 60
|
|
16
22
|
cleaned = []
|
|
17
23
|
i = 0
|
|
18
24
|
while i < len(args):
|
|
19
25
|
arg = args[i]
|
|
20
|
-
if arg in ("--json", "--once", "--at-reset", "--next-ready"):
|
|
26
|
+
if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh"):
|
|
21
27
|
i += 1
|
|
22
28
|
continue
|
|
23
29
|
if arg == "--poll":
|
|
24
30
|
if i + 1 >= len(args):
|
|
25
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
|
|
31
|
+
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
|
|
26
32
|
try:
|
|
27
33
|
poll = max(1, int(args[i + 1]))
|
|
28
34
|
except ValueError as error:
|
|
@@ -37,48 +43,68 @@ def parse_notify_args(args):
|
|
|
37
43
|
i += 1
|
|
38
44
|
continue
|
|
39
45
|
if arg.startswith("-"):
|
|
40
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
|
|
46
|
+
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
|
|
41
47
|
cleaned.append(arg)
|
|
42
48
|
i += 1
|
|
43
49
|
if at_reset == next_ready:
|
|
44
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
|
|
50
|
+
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
|
|
45
51
|
if at_reset and len(cleaned) != 1:
|
|
46
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once]")
|
|
52
|
+
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh]")
|
|
47
53
|
if next_ready and cleaned:
|
|
48
|
-
raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once]")
|
|
54
|
+
raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
|
|
49
55
|
return {
|
|
50
56
|
"name": cleaned[0] if cleaned else None,
|
|
51
57
|
"mode": "at-reset" if at_reset else "next-ready",
|
|
52
58
|
"poll": poll,
|
|
53
59
|
"once": once,
|
|
54
60
|
"json": json_flag,
|
|
61
|
+
"refresh": refresh,
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
|
|
58
|
-
def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None):
|
|
65
|
+
def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None, progress_callback=None):
|
|
59
66
|
notifier = notifier or send_desktop_notification
|
|
60
67
|
sleep_fn = sleep_fn or time.sleep
|
|
61
68
|
now_fn = now_fn or time.time
|
|
62
69
|
while True:
|
|
63
|
-
|
|
70
|
+
if progress_callback:
|
|
71
|
+
progress_callback({"event": "notify_check_started", "mode": parsed["mode"], "session_name": parsed["name"]})
|
|
72
|
+
event = resolve_notify_event(
|
|
73
|
+
service["get_status_rows"](
|
|
74
|
+
progress_callback=progress_callback,
|
|
75
|
+
force_refresh=parsed.get("refresh", False),
|
|
76
|
+
),
|
|
77
|
+
parsed,
|
|
78
|
+
now_fn(),
|
|
79
|
+
)
|
|
64
80
|
if event["ready"] or parsed["once"]:
|
|
65
81
|
if event["ready"]:
|
|
66
82
|
notifier(event["title"], event["message"])
|
|
67
83
|
return event
|
|
84
|
+
if progress_callback:
|
|
85
|
+
progress_callback({
|
|
86
|
+
"event": "notify_waiting",
|
|
87
|
+
"message": event["message"],
|
|
88
|
+
"poll": parsed["poll"],
|
|
89
|
+
"session_name": event.get("session"),
|
|
90
|
+
"target_timestamp": event.get("target_timestamp"),
|
|
91
|
+
})
|
|
68
92
|
sleep_fn(parsed["poll"])
|
|
69
93
|
|
|
70
94
|
|
|
71
95
|
def resolve_notify_event(rows, parsed, now_ts=None):
|
|
72
96
|
now_ts = time.time() if now_ts is None else now_ts
|
|
73
97
|
if parsed["mode"] == "next-ready":
|
|
74
|
-
|
|
98
|
+
active_rows = [row for row in rows if row.get("enabled", True) is not False]
|
|
99
|
+
priority = _recommend_priority_sessions([
|
|
100
|
+
row for row in active_rows
|
|
101
|
+
if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
|
|
102
|
+
])
|
|
75
103
|
if not priority:
|
|
76
|
-
return _event(False, "cdx", "No session
|
|
104
|
+
return _event(False, "cdx", "No upcoming session reset available", None)
|
|
77
105
|
first = priority[0]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
timestamp = _next_reset_timestamp(first)
|
|
81
|
-
if timestamp is not None and timestamp <= now_ts:
|
|
106
|
+
timestamp = _priority_reset_timestamp(first)
|
|
107
|
+
if _needs_refresh(first, timestamp, now_ts):
|
|
82
108
|
return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
|
|
83
109
|
return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
|
|
84
110
|
|
|
@@ -93,9 +119,16 @@ def resolve_notify_event(rows, parsed, now_ts=None):
|
|
|
93
119
|
return _event(False, "cdx", f"Waiting for {row['session_name']} reset", row["session_name"], timestamp)
|
|
94
120
|
|
|
95
121
|
|
|
96
|
-
def
|
|
122
|
+
def _is_usable_now(row):
|
|
123
|
+
value = row.get("available_pct")
|
|
124
|
+
return value is not None and value > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _needs_refresh(row, reset_timestamp, now_ts):
|
|
97
128
|
value = row.get("available_pct")
|
|
98
|
-
|
|
129
|
+
if value is None or value > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
|
|
130
|
+
return False
|
|
131
|
+
return reset_timestamp is not None and reset_timestamp < now_ts
|
|
99
132
|
|
|
100
133
|
|
|
101
134
|
def _next_reset_timestamp(row):
|
package/src/session_service.py
CHANGED
|
@@ -45,6 +45,8 @@ RESERVED_SESSION_NAMES = {
|
|
|
45
45
|
"--version",
|
|
46
46
|
"-v",
|
|
47
47
|
}
|
|
48
|
+
STATUS_CACHE_TTL_SECONDS = 60
|
|
49
|
+
CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
def _encode(name):
|
|
@@ -133,6 +135,24 @@ def _parse_status_timestamp(value):
|
|
|
133
135
|
return None
|
|
134
136
|
|
|
135
137
|
|
|
138
|
+
def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
139
|
+
if session.get("provider") == PROVIDER_CLAUDE and ttl_seconds == STATUS_CACHE_TTL_SECONDS:
|
|
140
|
+
return CLAUDE_STATUS_CACHE_TTL_SECONDS
|
|
141
|
+
return ttl_seconds
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_status_cache_fresh(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
145
|
+
status = session.get("lastStatus") or {}
|
|
146
|
+
if _is_low_confidence_status_source(status):
|
|
147
|
+
return False
|
|
148
|
+
updated_at = _parse_status_timestamp(status.get("updated_at") or session.get("lastStatusAt"))
|
|
149
|
+
if not updated_at:
|
|
150
|
+
return False
|
|
151
|
+
now = datetime.now(timezone.utc).astimezone()
|
|
152
|
+
ttl_seconds = _status_cache_ttl_seconds(session, ttl_seconds)
|
|
153
|
+
return (now - updated_at.astimezone(now.tzinfo)).total_seconds() < ttl_seconds
|
|
154
|
+
|
|
155
|
+
|
|
136
156
|
def _is_status_newer(candidate, current):
|
|
137
157
|
if not candidate:
|
|
138
158
|
return False
|
|
@@ -295,22 +315,39 @@ def create_session_service(options=None):
|
|
|
295
315
|
"auth": session.get("auth"),
|
|
296
316
|
}
|
|
297
317
|
|
|
298
|
-
def
|
|
299
|
-
|
|
318
|
+
def _auth_bundle_paths(provider):
|
|
319
|
+
if provider == PROVIDER_CLAUDE:
|
|
320
|
+
return [
|
|
321
|
+
"claude-home/.claude/.credentials.json",
|
|
322
|
+
"claude-home/.claude.json",
|
|
323
|
+
"claude-home/auth.json",
|
|
324
|
+
]
|
|
325
|
+
return [
|
|
326
|
+
"auth.json",
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
|
|
300
330
|
files = []
|
|
331
|
+
total_bytes = 0
|
|
301
332
|
if not os.path.isdir(session_root):
|
|
302
|
-
return files
|
|
303
|
-
for
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
333
|
+
return {"files": files, "file_count": 0, "bytes": 0}
|
|
334
|
+
for rel_path in _auth_bundle_paths(provider):
|
|
335
|
+
full_path = os.path.join(session_root, rel_path)
|
|
336
|
+
if not os.path.isfile(full_path):
|
|
337
|
+
continue
|
|
338
|
+
with open(full_path, "rb") as handle:
|
|
339
|
+
raw_content = handle.read()
|
|
340
|
+
total_bytes += len(raw_content)
|
|
341
|
+
content = base64.b64encode(raw_content).decode("ascii")
|
|
342
|
+
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
343
|
+
if progress_callback:
|
|
344
|
+
progress_callback({
|
|
345
|
+
"event": "profile_progress",
|
|
346
|
+
"session_name": session_name,
|
|
347
|
+
"file_count": len(files),
|
|
348
|
+
"bytes": total_bytes,
|
|
349
|
+
})
|
|
350
|
+
return {"files": files, "file_count": len(files), "bytes": total_bytes}
|
|
314
351
|
|
|
315
352
|
def _resolve_session_subset(session_names):
|
|
316
353
|
if not session_names:
|
|
@@ -555,8 +592,12 @@ def create_session_service(options=None):
|
|
|
555
592
|
raise CdxError(f"Unknown session: {name}")
|
|
556
593
|
return updated
|
|
557
594
|
|
|
558
|
-
def _resolve_session_status(session):
|
|
595
|
+
def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
559
596
|
current_status = session.get("lastStatus")
|
|
597
|
+
if session.get("enabled", True) is False:
|
|
598
|
+
return current_status
|
|
599
|
+
if current_status and not force_refresh and _is_status_cache_fresh(session, ttl_seconds=cache_ttl_seconds):
|
|
600
|
+
return current_status
|
|
560
601
|
source_root = session.get("authHome") or _get_session_auth_home(
|
|
561
602
|
session["name"], session["provider"]
|
|
562
603
|
)
|
|
@@ -627,11 +668,40 @@ def create_session_service(options=None):
|
|
|
627
668
|
raise CdxError(f"Unknown session: {name}")
|
|
628
669
|
return updated
|
|
629
670
|
|
|
630
|
-
def get_status_rows():
|
|
671
|
+
def get_status_rows(progress_callback=None, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
631
672
|
sessions = list_sessions()
|
|
673
|
+
if progress_callback:
|
|
674
|
+
progress_callback({
|
|
675
|
+
"event": "status_started",
|
|
676
|
+
"session_count": len(sessions),
|
|
677
|
+
})
|
|
632
678
|
resolved = []
|
|
633
679
|
for s in sessions:
|
|
634
|
-
|
|
680
|
+
cache_hit = (
|
|
681
|
+
s.get("enabled", True) is False
|
|
682
|
+
or (
|
|
683
|
+
s.get("lastStatus")
|
|
684
|
+
and not force_refresh
|
|
685
|
+
and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
if progress_callback and not cache_hit:
|
|
689
|
+
progress_callback({
|
|
690
|
+
"event": "session_started",
|
|
691
|
+
"session_name": s["name"],
|
|
692
|
+
"provider": s["provider"],
|
|
693
|
+
})
|
|
694
|
+
status = _resolve_session_status(
|
|
695
|
+
s,
|
|
696
|
+
force_refresh=force_refresh,
|
|
697
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
698
|
+
)
|
|
699
|
+
if progress_callback:
|
|
700
|
+
progress_callback({
|
|
701
|
+
"event": "session_finished",
|
|
702
|
+
"session_name": s["name"],
|
|
703
|
+
"has_status": bool(status),
|
|
704
|
+
})
|
|
635
705
|
resolved.append({
|
|
636
706
|
**s,
|
|
637
707
|
"lastStatus": status,
|
|
@@ -653,19 +723,27 @@ def create_session_service(options=None):
|
|
|
653
723
|
rows = []
|
|
654
724
|
for s in resolved:
|
|
655
725
|
status = s.get("lastStatus")
|
|
726
|
+
enabled = s.get("enabled", True) is not False
|
|
727
|
+
row_status = status if enabled else None
|
|
656
728
|
rows.append({
|
|
657
729
|
"session_name": s["name"],
|
|
658
730
|
"provider": s["provider"],
|
|
659
|
-
"enabled":
|
|
660
|
-
"status": "
|
|
661
|
-
"remaining_5h_pct":
|
|
662
|
-
"remaining_week_pct":
|
|
663
|
-
"credits":
|
|
664
|
-
"available_pct": _compute_available_pct(
|
|
665
|
-
"reset_5h_at":
|
|
666
|
-
"reset_week_at":
|
|
667
|
-
"reset_at":
|
|
731
|
+
"enabled": enabled,
|
|
732
|
+
"status": "enabled" if enabled else "disabled",
|
|
733
|
+
"remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
|
|
734
|
+
"remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
|
|
735
|
+
"credits": row_status.get("credits") if row_status else None,
|
|
736
|
+
"available_pct": _compute_available_pct(row_status),
|
|
737
|
+
"reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
|
|
738
|
+
"reset_week_at": row_status.get("reset_week_at") if row_status else None,
|
|
739
|
+
"reset_at": row_status.get("reset_at") if row_status else None,
|
|
668
740
|
"updated_at": _to_local_iso(s.get("lastStatusAt")),
|
|
741
|
+
"last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
|
|
742
|
+
})
|
|
743
|
+
if progress_callback:
|
|
744
|
+
progress_callback({
|
|
745
|
+
"event": "status_finished",
|
|
746
|
+
"row_count": len(rows),
|
|
669
747
|
})
|
|
670
748
|
return rows
|
|
671
749
|
|
|
@@ -695,7 +773,7 @@ def create_session_service(options=None):
|
|
|
695
773
|
def get_session_root(name):
|
|
696
774
|
return _get_session_root(name)
|
|
697
775
|
|
|
698
|
-
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False):
|
|
776
|
+
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
|
|
699
777
|
if not file_path:
|
|
700
778
|
raise CdxError("Export path is required.")
|
|
701
779
|
if os.path.exists(file_path) and not force:
|
|
@@ -710,16 +788,36 @@ def create_session_service(options=None):
|
|
|
710
788
|
"states": {},
|
|
711
789
|
"profiles": {},
|
|
712
790
|
}
|
|
791
|
+
profile_file_count = 0
|
|
792
|
+
profile_bytes = 0
|
|
793
|
+
if progress_callback:
|
|
794
|
+
progress_callback({
|
|
795
|
+
"event": "export_started",
|
|
796
|
+
"include_auth": bool(include_auth),
|
|
797
|
+
"session_count": len(sessions),
|
|
798
|
+
"session_names": [session["name"] for session in sessions],
|
|
799
|
+
})
|
|
713
800
|
for session in sessions:
|
|
801
|
+
if progress_callback:
|
|
802
|
+
progress_callback({"event": "session_started", "session_name": session["name"]})
|
|
714
803
|
payload["sessions"].append(_build_export_session_record(session))
|
|
715
804
|
state = store["read_session_state"](session["name"])
|
|
716
805
|
if state is not None:
|
|
717
806
|
payload["states"][session["name"]] = state
|
|
718
807
|
if include_auth:
|
|
719
808
|
session_root = session.get("sessionRoot") or _get_session_root(session["name"])
|
|
720
|
-
|
|
721
|
-
|
|
809
|
+
profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
|
|
810
|
+
payload["profiles"][session["name"]] = profile["files"]
|
|
811
|
+
profile_file_count += profile["file_count"]
|
|
812
|
+
profile_bytes += profile["bytes"]
|
|
813
|
+
if progress_callback:
|
|
814
|
+
progress_callback({"event": "session_finished", "session_name": session["name"]})
|
|
815
|
+
|
|
816
|
+
if progress_callback:
|
|
817
|
+
progress_callback({"event": "encoding_started"})
|
|
722
818
|
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
819
|
+
if progress_callback:
|
|
820
|
+
progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
|
|
723
821
|
_ensure_private_dir(os.path.dirname(os.path.abspath(file_path)) or ".")
|
|
724
822
|
with open(file_path, "wb") as handle:
|
|
725
823
|
handle.write(bundle_bytes)
|
|
@@ -732,6 +830,10 @@ def create_session_service(options=None):
|
|
|
732
830
|
"path": file_path,
|
|
733
831
|
"include_auth": include_auth,
|
|
734
832
|
"session_names": [session["name"] for session in sessions],
|
|
833
|
+
"session_count": len(sessions),
|
|
834
|
+
"profile_file_count": profile_file_count if include_auth else None,
|
|
835
|
+
"profile_bytes": profile_bytes if include_auth else None,
|
|
836
|
+
"bundle_size_bytes": len(bundle_bytes),
|
|
735
837
|
}
|
|
736
838
|
|
|
737
839
|
def import_bundle(file_path, passphrase=None, session_names=None, force=False):
|
package/src/status_view.py
CHANGED
|
@@ -9,6 +9,9 @@ from .cli_render import (
|
|
|
9
9
|
_style_pct,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
RESET_COUNTDOWN_SAFETY_SECONDS = 60
|
|
13
|
+
PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
def _format_reset_time(value):
|
|
14
17
|
if not value:
|
|
@@ -27,6 +30,7 @@ def _format_reset_time(value):
|
|
|
27
30
|
if hours_ago < 24:
|
|
28
31
|
return f"passed {hours_ago}h ago"
|
|
29
32
|
return value
|
|
33
|
+
delta_s = delta_s + RESET_COUNTDOWN_SAFETY_SECONDS
|
|
30
34
|
if delta_s < 60:
|
|
31
35
|
return "now"
|
|
32
36
|
if delta_s < 24 * 60 * 60:
|
|
@@ -78,17 +82,20 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
78
82
|
base.append(r.get("provider") or "n/a")
|
|
79
83
|
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
80
84
|
base.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
if r.get("enabled", True) is False:
|
|
86
|
+
usage_columns = [_style("-", "2", use_color)] * 5
|
|
87
|
+
else:
|
|
88
|
+
usage_columns = [
|
|
89
|
+
_style_pct(r.get("available_pct"), use_color),
|
|
90
|
+
_style_pct(r.get("remaining_5h_pct"), use_color),
|
|
91
|
+
_style_pct(r.get("remaining_week_pct"), use_color),
|
|
92
|
+
_style_reset_time(r.get("reset_5h_at"), use_color),
|
|
93
|
+
_style_reset_time(r.get("reset_week_at"), use_color),
|
|
94
|
+
]
|
|
88
95
|
if small:
|
|
89
96
|
base += usage_columns
|
|
90
97
|
else:
|
|
91
|
-
block = _format_blocking_quota(r)
|
|
98
|
+
block = "-" if r.get("enabled", True) is False else _format_blocking_quota(r)
|
|
92
99
|
credits = str(r["credits"]) if r.get("credits") is not None else "-"
|
|
93
100
|
base += usage_columns[:3] + [
|
|
94
101
|
_style(block, "33" if block not in ("?", "-") else "2", use_color),
|
|
@@ -104,14 +111,28 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
104
111
|
if len(priority) > 1 else "."
|
|
105
112
|
)
|
|
106
113
|
) if priority else "Priority: no usable session status yet."
|
|
114
|
+
current_line = _format_current_session_line(rows)
|
|
107
115
|
return "\n".join([
|
|
108
116
|
_pad_table([headers] + table_rows),
|
|
109
117
|
"",
|
|
110
118
|
_style(priority_line, "1", use_color),
|
|
111
|
-
_style(
|
|
119
|
+
_style(current_line, "2", use_color),
|
|
112
120
|
])
|
|
113
121
|
|
|
114
122
|
|
|
123
|
+
def _format_current_session_line(rows):
|
|
124
|
+
launched = []
|
|
125
|
+
for row in rows:
|
|
126
|
+
timestamp = _parse_reset_timestamp(row.get("last_launched_at"))
|
|
127
|
+
if timestamp is not None:
|
|
128
|
+
launched.append((timestamp, row))
|
|
129
|
+
if not launched:
|
|
130
|
+
return "Current: no launched session known yet."
|
|
131
|
+
timestamp, row = max(launched, key=lambda item: (item[0], item[1].get("session_name") or ""))
|
|
132
|
+
label = _format_relative_age(row.get("last_launched_at"))
|
|
133
|
+
return f"Current: last launched {row['session_name']} ({label})."
|
|
134
|
+
|
|
135
|
+
|
|
115
136
|
def _recommend_priority_sessions(rows):
|
|
116
137
|
if not rows:
|
|
117
138
|
return []
|
|
@@ -120,7 +141,7 @@ def _recommend_priority_sessions(rows):
|
|
|
120
141
|
has_credits = row.get("credits") is not None
|
|
121
142
|
credit_rank = 0 if has_credits else 1
|
|
122
143
|
available = row.get("available_pct")
|
|
123
|
-
usable_now = available is not None and available >
|
|
144
|
+
usable_now = available is not None and available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
|
|
124
145
|
known_available = available is not None
|
|
125
146
|
reset_timestamp = _priority_reset_timestamp(row)
|
|
126
147
|
reset_is_future = reset_timestamp is not None and reset_timestamp >= _now_timestamp()
|
|
@@ -165,7 +186,7 @@ def _priority_instruction(row, position):
|
|
|
165
186
|
|
|
166
187
|
def _priority_needs_refresh(row):
|
|
167
188
|
available = row.get("available_pct")
|
|
168
|
-
if available is None or available >
|
|
189
|
+
if available is None or available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
|
|
169
190
|
return False
|
|
170
191
|
_label, is_past = _priority_reset_info(row)
|
|
171
192
|
return is_past
|
|
@@ -175,7 +196,7 @@ def _priority_reason(row):
|
|
|
175
196
|
available = row.get("available_pct")
|
|
176
197
|
if available is None:
|
|
177
198
|
return "status unknown"
|
|
178
|
-
if available >
|
|
199
|
+
if available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
|
|
179
200
|
return f"{_format_pct(available)} OK"
|
|
180
201
|
label, is_past = _priority_reset_info(row)
|
|
181
202
|
if label:
|