cdx-manager 0.5.4 → 0.5.5
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 +4 -4
- package/changelogs/CHANGELOGS_0_5_5.md +48 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +33 -1
- package/src/cli.py +1 -1
- package/src/cli_commands.py +74 -4
- package/src/session_service.py +82 -18
- package/src/status_view.py +22 -4
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.5 sh install.sh
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
From source:
|
|
@@ -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`
|
package/package.json
CHANGED
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
package/src/cli_commands.py
CHANGED
|
@@ -69,6 +69,76 @@ 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
|
+
|
|
72
142
|
def _latest_launch_transcript_path(session):
|
|
73
143
|
paths = _list_launch_transcript_paths(session)
|
|
74
144
|
if not paths:
|
|
@@ -696,7 +766,8 @@ def handle_status(rest, ctx):
|
|
|
696
766
|
for item in refresh_errors
|
|
697
767
|
]
|
|
698
768
|
|
|
699
|
-
|
|
769
|
+
status_progress = None if parsed["json"] else _make_status_progress(ctx)
|
|
770
|
+
rows = ctx["service"]["get_status_rows"](progress_callback=status_progress)
|
|
700
771
|
if len(args) == 0:
|
|
701
772
|
if parsed["json"]:
|
|
702
773
|
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
|
|
@@ -740,6 +811,7 @@ def handle_export(rest, ctx):
|
|
|
740
811
|
session_names=parsed["session_names"],
|
|
741
812
|
passphrase=passphrase,
|
|
742
813
|
force=parsed["force"],
|
|
814
|
+
progress_callback=None if parsed["json"] else _make_export_progress(ctx),
|
|
743
815
|
)
|
|
744
816
|
session_count = len(result["session_names"])
|
|
745
817
|
auth_suffix = " with auth" if result["include_auth"] else ""
|
|
@@ -753,6 +825,7 @@ def handle_export(rest, ctx):
|
|
|
753
825
|
_write_json(ctx, payload)
|
|
754
826
|
return 0
|
|
755
827
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
828
|
+
ctx["out"](f"{_format_export_report(result)}\n")
|
|
756
829
|
return 0
|
|
757
830
|
|
|
758
831
|
|
|
@@ -961,9 +1034,6 @@ def handle_launch(command, ctx, initial_prompt=None):
|
|
|
961
1034
|
if update_notice.get("url"):
|
|
962
1035
|
text = f"{text} {update_notice['url']}"
|
|
963
1036
|
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
1037
|
_run_interactive_provider_command(
|
|
968
1038
|
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
969
1039
|
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
package/src/session_service.py
CHANGED
|
@@ -295,22 +295,39 @@ def create_session_service(options=None):
|
|
|
295
295
|
"auth": session.get("auth"),
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
def
|
|
299
|
-
|
|
298
|
+
def _auth_bundle_paths(provider):
|
|
299
|
+
if provider == PROVIDER_CLAUDE:
|
|
300
|
+
return [
|
|
301
|
+
"claude-home/.claude/.credentials.json",
|
|
302
|
+
"claude-home/.claude.json",
|
|
303
|
+
"claude-home/auth.json",
|
|
304
|
+
]
|
|
305
|
+
return [
|
|
306
|
+
"auth.json",
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
|
|
300
310
|
files = []
|
|
311
|
+
total_bytes = 0
|
|
301
312
|
if not os.path.isdir(session_root):
|
|
302
|
-
return files
|
|
303
|
-
for
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
return {"files": files, "file_count": 0, "bytes": 0}
|
|
314
|
+
for rel_path in _auth_bundle_paths(provider):
|
|
315
|
+
full_path = os.path.join(session_root, rel_path)
|
|
316
|
+
if not os.path.isfile(full_path):
|
|
317
|
+
continue
|
|
318
|
+
with open(full_path, "rb") as handle:
|
|
319
|
+
raw_content = handle.read()
|
|
320
|
+
total_bytes += len(raw_content)
|
|
321
|
+
content = base64.b64encode(raw_content).decode("ascii")
|
|
322
|
+
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
323
|
+
if progress_callback:
|
|
324
|
+
progress_callback({
|
|
325
|
+
"event": "profile_progress",
|
|
326
|
+
"session_name": session_name,
|
|
327
|
+
"file_count": len(files),
|
|
328
|
+
"bytes": total_bytes,
|
|
329
|
+
})
|
|
330
|
+
return {"files": files, "file_count": len(files), "bytes": total_bytes}
|
|
314
331
|
|
|
315
332
|
def _resolve_session_subset(session_names):
|
|
316
333
|
if not session_names:
|
|
@@ -627,11 +644,28 @@ def create_session_service(options=None):
|
|
|
627
644
|
raise CdxError(f"Unknown session: {name}")
|
|
628
645
|
return updated
|
|
629
646
|
|
|
630
|
-
def get_status_rows():
|
|
647
|
+
def get_status_rows(progress_callback=None):
|
|
631
648
|
sessions = list_sessions()
|
|
649
|
+
if progress_callback:
|
|
650
|
+
progress_callback({
|
|
651
|
+
"event": "status_started",
|
|
652
|
+
"session_count": len(sessions),
|
|
653
|
+
})
|
|
632
654
|
resolved = []
|
|
633
655
|
for s in sessions:
|
|
656
|
+
if progress_callback:
|
|
657
|
+
progress_callback({
|
|
658
|
+
"event": "session_started",
|
|
659
|
+
"session_name": s["name"],
|
|
660
|
+
"provider": s["provider"],
|
|
661
|
+
})
|
|
634
662
|
status = _resolve_session_status(s)
|
|
663
|
+
if progress_callback:
|
|
664
|
+
progress_callback({
|
|
665
|
+
"event": "session_finished",
|
|
666
|
+
"session_name": s["name"],
|
|
667
|
+
"has_status": bool(status),
|
|
668
|
+
})
|
|
635
669
|
resolved.append({
|
|
636
670
|
**s,
|
|
637
671
|
"lastStatus": status,
|
|
@@ -666,6 +700,12 @@ def create_session_service(options=None):
|
|
|
666
700
|
"reset_week_at": status.get("reset_week_at") if status else None,
|
|
667
701
|
"reset_at": status.get("reset_at") if status else None,
|
|
668
702
|
"updated_at": _to_local_iso(s.get("lastStatusAt")),
|
|
703
|
+
"last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
|
|
704
|
+
})
|
|
705
|
+
if progress_callback:
|
|
706
|
+
progress_callback({
|
|
707
|
+
"event": "status_finished",
|
|
708
|
+
"row_count": len(rows),
|
|
669
709
|
})
|
|
670
710
|
return rows
|
|
671
711
|
|
|
@@ -695,7 +735,7 @@ def create_session_service(options=None):
|
|
|
695
735
|
def get_session_root(name):
|
|
696
736
|
return _get_session_root(name)
|
|
697
737
|
|
|
698
|
-
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False):
|
|
738
|
+
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
|
|
699
739
|
if not file_path:
|
|
700
740
|
raise CdxError("Export path is required.")
|
|
701
741
|
if os.path.exists(file_path) and not force:
|
|
@@ -710,16 +750,36 @@ def create_session_service(options=None):
|
|
|
710
750
|
"states": {},
|
|
711
751
|
"profiles": {},
|
|
712
752
|
}
|
|
753
|
+
profile_file_count = 0
|
|
754
|
+
profile_bytes = 0
|
|
755
|
+
if progress_callback:
|
|
756
|
+
progress_callback({
|
|
757
|
+
"event": "export_started",
|
|
758
|
+
"include_auth": bool(include_auth),
|
|
759
|
+
"session_count": len(sessions),
|
|
760
|
+
"session_names": [session["name"] for session in sessions],
|
|
761
|
+
})
|
|
713
762
|
for session in sessions:
|
|
763
|
+
if progress_callback:
|
|
764
|
+
progress_callback({"event": "session_started", "session_name": session["name"]})
|
|
714
765
|
payload["sessions"].append(_build_export_session_record(session))
|
|
715
766
|
state = store["read_session_state"](session["name"])
|
|
716
767
|
if state is not None:
|
|
717
768
|
payload["states"][session["name"]] = state
|
|
718
769
|
if include_auth:
|
|
719
770
|
session_root = session.get("sessionRoot") or _get_session_root(session["name"])
|
|
720
|
-
|
|
721
|
-
|
|
771
|
+
profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
|
|
772
|
+
payload["profiles"][session["name"]] = profile["files"]
|
|
773
|
+
profile_file_count += profile["file_count"]
|
|
774
|
+
profile_bytes += profile["bytes"]
|
|
775
|
+
if progress_callback:
|
|
776
|
+
progress_callback({"event": "session_finished", "session_name": session["name"]})
|
|
777
|
+
|
|
778
|
+
if progress_callback:
|
|
779
|
+
progress_callback({"event": "encoding_started"})
|
|
722
780
|
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
781
|
+
if progress_callback:
|
|
782
|
+
progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
|
|
723
783
|
_ensure_private_dir(os.path.dirname(os.path.abspath(file_path)) or ".")
|
|
724
784
|
with open(file_path, "wb") as handle:
|
|
725
785
|
handle.write(bundle_bytes)
|
|
@@ -732,6 +792,10 @@ def create_session_service(options=None):
|
|
|
732
792
|
"path": file_path,
|
|
733
793
|
"include_auth": include_auth,
|
|
734
794
|
"session_names": [session["name"] for session in sessions],
|
|
795
|
+
"session_count": len(sessions),
|
|
796
|
+
"profile_file_count": profile_file_count if include_auth else None,
|
|
797
|
+
"profile_bytes": profile_bytes if include_auth else None,
|
|
798
|
+
"bundle_size_bytes": len(bundle_bytes),
|
|
735
799
|
}
|
|
736
800
|
|
|
737
801
|
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:
|
|
@@ -104,14 +108,28 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
104
108
|
if len(priority) > 1 else "."
|
|
105
109
|
)
|
|
106
110
|
) if priority else "Priority: no usable session status yet."
|
|
111
|
+
current_line = _format_current_session_line(rows)
|
|
107
112
|
return "\n".join([
|
|
108
113
|
_pad_table([headers] + table_rows),
|
|
109
114
|
"",
|
|
110
115
|
_style(priority_line, "1", use_color),
|
|
111
|
-
_style(
|
|
116
|
+
_style(current_line, "2", use_color),
|
|
112
117
|
])
|
|
113
118
|
|
|
114
119
|
|
|
120
|
+
def _format_current_session_line(rows):
|
|
121
|
+
launched = []
|
|
122
|
+
for row in rows:
|
|
123
|
+
timestamp = _parse_reset_timestamp(row.get("last_launched_at"))
|
|
124
|
+
if timestamp is not None:
|
|
125
|
+
launched.append((timestamp, row))
|
|
126
|
+
if not launched:
|
|
127
|
+
return "Current: no launched session known yet."
|
|
128
|
+
timestamp, row = max(launched, key=lambda item: (item[0], item[1].get("session_name") or ""))
|
|
129
|
+
label = _format_relative_age(row.get("last_launched_at"))
|
|
130
|
+
return f"Current: last launched {row['session_name']} ({label})."
|
|
131
|
+
|
|
132
|
+
|
|
115
133
|
def _recommend_priority_sessions(rows):
|
|
116
134
|
if not rows:
|
|
117
135
|
return []
|
|
@@ -120,7 +138,7 @@ def _recommend_priority_sessions(rows):
|
|
|
120
138
|
has_credits = row.get("credits") is not None
|
|
121
139
|
credit_rank = 0 if has_credits else 1
|
|
122
140
|
available = row.get("available_pct")
|
|
123
|
-
usable_now = available is not None and available >
|
|
141
|
+
usable_now = available is not None and available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
|
|
124
142
|
known_available = available is not None
|
|
125
143
|
reset_timestamp = _priority_reset_timestamp(row)
|
|
126
144
|
reset_is_future = reset_timestamp is not None and reset_timestamp >= _now_timestamp()
|
|
@@ -165,7 +183,7 @@ def _priority_instruction(row, position):
|
|
|
165
183
|
|
|
166
184
|
def _priority_needs_refresh(row):
|
|
167
185
|
available = row.get("available_pct")
|
|
168
|
-
if available is None or available >
|
|
186
|
+
if available is None or available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
|
|
169
187
|
return False
|
|
170
188
|
_label, is_past = _priority_reset_info(row)
|
|
171
189
|
return is_past
|
|
@@ -175,7 +193,7 @@ def _priority_reason(row):
|
|
|
175
193
|
available = row.get("available_pct")
|
|
176
194
|
if available is None:
|
|
177
195
|
return "status unknown"
|
|
178
|
-
if available >
|
|
196
|
+
if available > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
|
|
179
197
|
return f"{_format_pct(available)} OK"
|
|
180
198
|
label, is_past = _priority_reset_info(row)
|
|
181
199
|
if label:
|