cdx-manager 0.21.4 → 0.21.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 +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_5.md +26 -0
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +3 -3
- package/docs/operations.md +2 -0
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +16 -6
- package/src/backup_bundle.py +60 -16
- package/src/claude_credentials.py +85 -0
- package/src/claude_usage.py +1 -1
- package/src/cli.py +6 -8
- package/src/cli_args.py +43 -15
- package/src/cli_helpers.py +12 -5
- package/src/codex_usage.py +1 -1
- package/src/commands/auth.py +6 -0
- package/src/commands/runs.py +3 -3
- package/src/commands/tray.py +0 -20
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +24 -0
- package/src/health.py +20 -2
- package/src/notify.py +40 -30
- package/src/provider_background.py +1 -1
- package/src/provider_runtime.py +55 -22
- package/src/run_registry.py +19 -7
- package/src/run_usage.py +16 -5
- package/src/session_helpers.py +18 -0
- package/src/session_service.py +34 -92
- package/src/session_status.py +4 -0
- package/src/session_store.py +6 -27
- package/src/status_view.py +18 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +4 -15
- package/src/tray_logics.py +1 -1
- package/src/update_all.py +1 -1
- package/src/update_check.py +1 -1
- package/src/update_manager.py +7 -3
package/src/status_view.py
CHANGED
|
@@ -74,6 +74,17 @@ def _format_credits(value, empty="n/a"):
|
|
|
74
74
|
return str(value)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def _auth_type_label(row):
|
|
78
|
+
"""`setup-token`, or with its remaining days once renewal is due."""
|
|
79
|
+
from .claude_credentials import SETUP_TOKEN_WARN_DAYS
|
|
80
|
+
|
|
81
|
+
label = row.get("auth_type") or "-"
|
|
82
|
+
days = row.get("auth_expires_in_days")
|
|
83
|
+
if days is None or days > SETUP_TOKEN_WARN_DAYS:
|
|
84
|
+
return label, False
|
|
85
|
+
return (f"{label} expired" if days < 0 else f"{label} {days}d"), True
|
|
86
|
+
|
|
87
|
+
|
|
77
88
|
def _format_status_rows(rows, use_color=False, small=False):
|
|
78
89
|
active_rows = [r for r in rows if r.get("enabled", True) is not False]
|
|
79
90
|
def positive(value):
|
|
@@ -90,18 +101,19 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
90
101
|
credits_visible = any(r.get("credits") is not None for r in active_rows)
|
|
91
102
|
has_provider = len({r["provider"] for r in rows}) > 1 and not small
|
|
92
103
|
has_label = any(r.get("label") for r in rows) and not small
|
|
104
|
+
type_visible = any(r.get("auth_type") for r in active_rows) and not small
|
|
93
105
|
if small:
|
|
94
106
|
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK"] + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible]
|
|
95
107
|
elif has_provider:
|
|
96
108
|
headers = ["SESSION", "PROV."]
|
|
97
109
|
if has_label:
|
|
98
110
|
headers.append("LABEL")
|
|
99
|
-
headers += ["STATUS", "AUTH"
|
|
111
|
+
headers += ["STATUS", "AUTH"] + (["TYPE"] if type_visible else []) + ["OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
|
|
100
112
|
else:
|
|
101
113
|
headers = ["SESSION"]
|
|
102
114
|
if has_label:
|
|
103
115
|
headers.append("LABEL")
|
|
104
|
-
headers += ["STATUS", "AUTH"
|
|
116
|
+
headers += ["STATUS", "AUTH"] + (["TYPE"] if type_visible else []) + ["OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
|
|
105
117
|
if not rows:
|
|
106
118
|
if small:
|
|
107
119
|
return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
@@ -130,6 +142,9 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
130
142
|
auth = _format_auth_status(r)
|
|
131
143
|
auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
|
|
132
144
|
base.append(_style(auth, auth_color, use_color))
|
|
145
|
+
if type_visible:
|
|
146
|
+
label, warn = _auth_type_label(r)
|
|
147
|
+
base.append(_style(label, "33" if warn else "2", use_color))
|
|
133
148
|
if r.get("enabled", True) is False:
|
|
134
149
|
usage_columns = [_style("-", "2", use_color)] * 6
|
|
135
150
|
else:
|
|
@@ -365,6 +380,7 @@ def _format_status_detail(row, use_color=False):
|
|
|
365
380
|
f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
|
|
366
381
|
f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
|
|
367
382
|
f"{_style('Auth:', '1', use_color)} {_style(auth, auth_color, use_color)}",
|
|
383
|
+
*([f"{_style('Auth type:', '1', use_color)} {_auth_type_label(row)[0]}"] if row.get("auth_type") else []),
|
|
368
384
|
f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
|
|
369
385
|
f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
|
|
370
386
|
f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
|
package/src/tray_events.py
CHANGED
|
@@ -24,6 +24,8 @@ import json
|
|
|
24
24
|
import os
|
|
25
25
|
import time
|
|
26
26
|
|
|
27
|
+
from .errors import CdxError
|
|
28
|
+
|
|
27
29
|
SCHEMA_VERSION = 1
|
|
28
30
|
# The tray heartbeats once per poll: 30s native, 60s across WSL. This window has
|
|
29
31
|
# to clear the slowest of those with room for a late tick, or a live tray reads
|
|
@@ -130,12 +132,13 @@ def publish(base_dir, title, message, kind="complete", now=None, event_id=None,
|
|
|
130
132
|
path = events_path(base_dir)
|
|
131
133
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
132
134
|
line = json.dumps(event, ensure_ascii=False) + "\n"
|
|
133
|
-
# O_APPEND
|
|
134
|
-
#
|
|
135
|
-
|
|
135
|
+
# O_APPEND keeps concurrent hooks apart; the lock keeps this append
|
|
136
|
+
# out of acknowledge()'s read-and-replace, which would otherwise
|
|
137
|
+
# overwrite it and lose the notification.
|
|
138
|
+
with _spool_lock(path), open(path, "a", encoding="utf-8") as handle:
|
|
136
139
|
handle.write(line)
|
|
137
140
|
return True
|
|
138
|
-
except OSError:
|
|
141
|
+
except (OSError, CdxError):
|
|
139
142
|
return False
|
|
140
143
|
|
|
141
144
|
|
|
@@ -171,6 +174,11 @@ def read_events(base_dir, include_acknowledged=False):
|
|
|
171
174
|
return events[-MAX_SPOOLED_EVENTS:]
|
|
172
175
|
|
|
173
176
|
|
|
177
|
+
# A hook must never stall an agent's turn on this lock; giving up only means
|
|
178
|
+
# the alert is delivered directly instead.
|
|
179
|
+
SPOOL_LOCK_TIMEOUT_SECONDS = 2
|
|
180
|
+
|
|
181
|
+
|
|
174
182
|
def acknowledge(base_dir, ids, now=None):
|
|
175
183
|
"""Mark events consumed, idempotently, and compact when it is worth it.
|
|
176
184
|
|
|
@@ -183,6 +191,17 @@ def acknowledge(base_dir, ids, now=None):
|
|
|
183
191
|
if not wanted:
|
|
184
192
|
return {"acknowledged": 0, "compacted": False}
|
|
185
193
|
|
|
194
|
+
with _spool_lock(events_path(base_dir)):
|
|
195
|
+
return _acknowledge_locked(base_dir, wanted, now)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _spool_lock(path):
|
|
199
|
+
from .run_registry import _registry_lock
|
|
200
|
+
|
|
201
|
+
return _registry_lock(path, timeout_seconds=SPOOL_LOCK_TIMEOUT_SECONDS, label="tray events")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _acknowledge_locked(base_dir, wanted, now):
|
|
186
205
|
try:
|
|
187
206
|
with open(events_path(base_dir), encoding="utf-8") as handle:
|
|
188
207
|
lines = handle.readlines()
|
package/src/tray_instance.py
CHANGED
|
@@ -6,9 +6,10 @@ per-user temporary directory, then `cdx-tray/companion.pid`.
|
|
|
6
6
|
"""
|
|
7
7
|
import os
|
|
8
8
|
import platform
|
|
9
|
-
import subprocess
|
|
10
9
|
import tempfile
|
|
11
10
|
|
|
11
|
+
from .session_helpers import _process_is_running
|
|
12
|
+
|
|
12
13
|
|
|
13
14
|
def lock_path(env=None):
|
|
14
15
|
env = os.environ if env is None else env
|
|
@@ -20,20 +21,8 @@ def lock_path(env=None):
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def _is_alive(pid):
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
["tasklist", "/fi", f"PID eq {pid}", "/nh"],
|
|
26
|
-
capture_output=True, text=True, check=False,
|
|
27
|
-
)
|
|
28
|
-
return str(pid) in result.stdout
|
|
29
|
-
try:
|
|
30
|
-
os.kill(pid, 0)
|
|
31
|
-
except (ProcessLookupError, ValueError):
|
|
32
|
-
return False
|
|
33
|
-
except PermissionError:
|
|
34
|
-
# Alive, owned by someone else. Still a reason not to start a second.
|
|
35
|
-
return True
|
|
36
|
-
return True
|
|
24
|
+
# Alive includes owned by someone else: still a reason not to start a second.
|
|
25
|
+
return _process_is_running(pid)
|
|
37
26
|
|
|
38
27
|
|
|
39
28
|
def companion_instance(env=None):
|
package/src/tray_logics.py
CHANGED
|
@@ -88,7 +88,7 @@ def _status(env=None, runner=None, executable=None, directories=None):
|
|
|
88
88
|
try:
|
|
89
89
|
completed = (runner or subprocess.run)(
|
|
90
90
|
[executable, "status", "--format", "json"], capture_output=True,
|
|
91
|
-
text=True, timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
|
|
91
|
+
text=True, encoding="utf-8", errors="replace", timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
|
|
92
92
|
)
|
|
93
93
|
payload = json.loads(completed.stdout) if getattr(completed, "returncode", 1) == 0 else None
|
|
94
94
|
except (OSError, subprocess.SubprocessError, TypeError, ValueError):
|
package/src/update_all.py
CHANGED
|
@@ -33,7 +33,7 @@ def _which(command, env):
|
|
|
33
33
|
def _run(command, env, runner=None, timeout=15):
|
|
34
34
|
runner = runner or subprocess.run
|
|
35
35
|
try:
|
|
36
|
-
result = runner(command, env=env, capture_output=True, text=True, check=False, timeout=timeout)
|
|
36
|
+
result = runner(command, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=timeout)
|
|
37
37
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
38
38
|
return {"returncode": 1, "stdout": "", "stderr": str(error)}
|
|
39
39
|
if isinstance(result, dict):
|
package/src/update_check.py
CHANGED
package/src/update_manager.py
CHANGED
|
@@ -43,7 +43,7 @@ def _is_git_dirty(package_root):
|
|
|
43
43
|
result = subprocess.run(
|
|
44
44
|
["git", "-C", str(package_root), "status", "--porcelain"],
|
|
45
45
|
capture_output=True,
|
|
46
|
-
text=True,
|
|
46
|
+
text=True, encoding="utf-8", errors="replace",
|
|
47
47
|
check=False,
|
|
48
48
|
)
|
|
49
49
|
except FileNotFoundError as error:
|
|
@@ -216,7 +216,11 @@ def _version_check_env(env):
|
|
|
216
216
|
source = env or os.environ
|
|
217
217
|
allowed = {
|
|
218
218
|
key: source[key]
|
|
219
|
-
|
|
219
|
+
# The home variables let the child find its home directory: without
|
|
220
|
+
# USERPROFILE, Windows Python's Path.home() raises and `cdx -v` prints
|
|
221
|
+
# a traceback instead of the version.
|
|
222
|
+
for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR",
|
|
223
|
+
"HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CDX_HOME")
|
|
220
224
|
if source.get(key)
|
|
221
225
|
}
|
|
222
226
|
return allowed
|
|
@@ -244,7 +248,7 @@ def verify_updated_command(target_version, runner=None, env=None):
|
|
|
244
248
|
env=_version_check_env(env),
|
|
245
249
|
check=False,
|
|
246
250
|
capture_output=True,
|
|
247
|
-
text=True,
|
|
251
|
+
text=True, encoding="utf-8", errors="replace",
|
|
248
252
|
)
|
|
249
253
|
except OSError as error:
|
|
250
254
|
return {
|