cdx-manager 0.21.5 → 0.22.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 +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/changelogs/CHANGELOGS_0_22_0.md +25 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +17 -7
- package/docs/operations.md +80 -0
- package/docs/sessions-and-notifications.md +1 -1
- package/docs/technical-overview.md +1 -1
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +23 -7
- package/src/backup_bundle.py +60 -16
- package/src/claude_usage.py +1 -1
- package/src/cli.py +15 -4
- package/src/cli_args.py +76 -19
- package/src/cli_helpers.py +23 -9
- package/src/codex_usage.py +1 -1
- package/src/commands/launch.py +41 -3
- package/src/commands/maintenance.py +1 -0
- package/src/commands/rescue.py +207 -0
- package/src/commands/runs.py +26 -3
- package/src/commands/status.py +56 -6
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +22 -11
- package/src/health.py +2 -2
- package/src/interactive_usage.py +20 -5
- package/src/notify.py +23 -18
- package/src/provider_background.py +13 -8
- package/src/provider_runtime.py +101 -8
- package/src/retro.py +131 -0
- package/src/rtk_savings.py +68 -0
- package/src/run_registry.py +27 -1
- package/src/run_usage.py +36 -9
- package/src/session_backup.py +16 -5
- package/src/session_helpers.py +4 -0
- package/src/session_ranking.py +10 -0
- package/src/session_service.py +39 -34
- package/src/session_status.py +86 -2
- package/src/shared_memory.py +102 -0
- package/src/status_source.py +2 -0
- package/src/status_view.py +23 -23
- package/src/tray_autostart.py +21 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +11 -2
- 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 +17 -7
package/src/status_view.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import datetime
|
|
1
|
+
from datetime import datetime
|
|
2
2
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
|
3
3
|
|
|
4
4
|
from .cli_render import (
|
|
@@ -10,6 +10,7 @@ from .cli_render import (
|
|
|
10
10
|
_style_pct,
|
|
11
11
|
)
|
|
12
12
|
from .session_ranking import rank_sessions
|
|
13
|
+
from .session_status import reset_iso
|
|
13
14
|
|
|
14
15
|
RESET_COUNTDOWN_SAFETY_SECONDS = 60
|
|
15
16
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
|
|
@@ -339,32 +340,21 @@ def _parse_reset_timestamp(value):
|
|
|
339
340
|
if not value:
|
|
340
341
|
return None
|
|
341
342
|
text = str(value).strip()
|
|
343
|
+
# A year-less display ("Dec 31 23:00") becomes ISO first: reset_iso picks
|
|
344
|
+
# the year closest to now and the offset valid on that date, so neither a
|
|
345
|
+
# new-year wrap nor a DST change shifts the reset.
|
|
346
|
+
display = reset_iso(text)
|
|
347
|
+
if display is not None:
|
|
348
|
+
if "T" not in display:
|
|
349
|
+
return None # a reset with no time of day has no countdown
|
|
350
|
+
text = display
|
|
342
351
|
try:
|
|
343
352
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
344
|
-
if parsed.tzinfo is None:
|
|
345
|
-
parsed = parsed.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
|
346
|
-
return parsed.timestamp()
|
|
347
353
|
except (TypeError, ValueError):
|
|
348
|
-
pass
|
|
349
|
-
now = datetime.now().astimezone()
|
|
350
|
-
parsed = None
|
|
351
|
-
# Parse with an explicit year: strptime's implicit 1900 rejects "Feb 29",
|
|
352
|
-
# and the current year may not hold the date at all around new year.
|
|
353
|
-
for year in (now.year, now.year + 1):
|
|
354
|
-
try:
|
|
355
|
-
parsed = datetime.strptime(f"{year} {text}", "%Y %b %d %H:%M").replace(tzinfo=now.tzinfo)
|
|
356
|
-
break
|
|
357
|
-
except (TypeError, ValueError):
|
|
358
|
-
continue
|
|
359
|
-
if parsed is None:
|
|
360
354
|
return None
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
try:
|
|
365
|
-
parsed = parsed.replace(year=parsed.year + 1)
|
|
366
|
-
except ValueError:
|
|
367
|
-
pass
|
|
355
|
+
if parsed.tzinfo is None:
|
|
356
|
+
# Naive means local; astimezone() resolves the offset for that date.
|
|
357
|
+
parsed = parsed.astimezone()
|
|
368
358
|
return parsed.timestamp()
|
|
369
359
|
|
|
370
360
|
|
|
@@ -390,11 +380,21 @@ def _format_status_detail(row, use_color=False):
|
|
|
390
380
|
f"{_style('Reset expiry:', '1', use_color)} {_style_reset_time(_nearest_reset_expiry(row), use_color)}",
|
|
391
381
|
f"{_style('5h reset:', '1', use_color)} {_style_reset_time(row.get('reset_5h_at'), use_color)}",
|
|
392
382
|
f"{_style('Week reset:', '1', use_color)} {_style_reset_time(row.get('reset_week_at'), use_color)}",
|
|
383
|
+
*([f"{_style('Runs out:', '1', use_color)} {_style(_format_runs_out(row['runs_out_at_iso']), '33', use_color)}"] if row.get("runs_out_at_iso") else []),
|
|
393
384
|
f"{_style('Updated:', '1', use_color)} {_dim(_format_relative_age(row.get('updated_at')), use_color)}",
|
|
394
385
|
]
|
|
395
386
|
return "\n".join(lines)
|
|
396
387
|
|
|
397
388
|
|
|
389
|
+
def _format_runs_out(iso):
|
|
390
|
+
"""`~16:20 at this pace` (or with the date when it is not today)."""
|
|
391
|
+
from datetime import datetime
|
|
392
|
+
|
|
393
|
+
when = datetime.fromisoformat(iso).astimezone()
|
|
394
|
+
stamp = when.strftime("%H:%M") if when.date() == datetime.now().astimezone().date() else when.strftime("%b %d %H:%M")
|
|
395
|
+
return f"~{stamp} at this pace"
|
|
396
|
+
|
|
397
|
+
|
|
398
398
|
def _nearest_reset_expiry(row):
|
|
399
399
|
values = [credit.get("expires_at") for credit in row.get("reset_credits") or [] if credit.get("expires_at")]
|
|
400
400
|
return min(values, key=lambda value: _parse_reset_timestamp(value) or float("inf")) if values else None
|
package/src/tray_autostart.py
CHANGED
|
@@ -18,8 +18,27 @@ RUN_KEY = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"
|
|
|
18
18
|
RUN_VALUE = "CDXTray"
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _operator_home():
|
|
22
|
+
"""The operator's real home from passwd. Inside a cdx session HOME is the
|
|
23
|
+
profile home, where launchd and the XDG autostart never look."""
|
|
24
|
+
try:
|
|
25
|
+
import pwd
|
|
26
|
+
|
|
27
|
+
return pwd.getpwuid(os.getuid()).pw_dir
|
|
28
|
+
except (ImportError, KeyError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
21
32
|
def _home(env):
|
|
22
|
-
return env.get("HOME") or os.path.expanduser("~")
|
|
33
|
+
return _operator_home() or env.get("HOME") or os.path.expanduser("~")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _desktop_exec(executable):
|
|
37
|
+
"""Quote one argument per the desktop-entry spec: backslash-escape the four
|
|
38
|
+
reserved characters inside double quotes, then apply the string-value escape
|
|
39
|
+
to backslashes, and double `%` so it is not read as a field code."""
|
|
40
|
+
quoted = "".join("\\" + c if c in '"`$\\' else c for c in executable)
|
|
41
|
+
return '"' + quoted.replace("\\", "\\\\").replace("%", "%%") + '"'
|
|
23
42
|
|
|
24
43
|
|
|
25
44
|
def artifact_path(env=None, system=None):
|
|
@@ -86,7 +105,7 @@ def enable(executable, env=None, system=None, run=None):
|
|
|
86
105
|
with open(path, "w", encoding="utf-8") as handle:
|
|
87
106
|
handle.write(
|
|
88
107
|
"[Desktop Entry]\nType=Application\nName=CDX Tray\n"
|
|
89
|
-
f"Exec={executable}\nX-GNOME-Autostart-enabled=true\n"
|
|
108
|
+
f"Exec={_desktop_exec(executable)}\nX-GNOME-Autostart-enabled=true\n"
|
|
90
109
|
)
|
|
91
110
|
return path
|
|
92
111
|
|
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
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The companion owns the lock; this only reads it. Both sides must agree on the
|
|
4
4
|
path, which is why the rule is written in one sentence in both places: the
|
|
5
|
-
per-user
|
|
5
|
+
per-user directory (LOCALAPPDATA on Windows, TMPDIR on macOS, XDG_RUNTIME_DIR on
|
|
6
|
+
Linux, else the temp dir with the uid in the folder name), then
|
|
7
|
+
`cdx-tray/companion.pid`.
|
|
6
8
|
"""
|
|
7
9
|
import os
|
|
8
10
|
import platform
|
|
@@ -13,8 +15,15 @@ from .session_helpers import _process_is_running
|
|
|
13
15
|
|
|
14
16
|
def lock_path(env=None):
|
|
15
17
|
env = os.environ if env is None else env
|
|
16
|
-
|
|
18
|
+
system = platform.system()
|
|
19
|
+
if system == "Windows":
|
|
17
20
|
base = env.get("LOCALAPPDATA") or tempfile.gettempdir()
|
|
21
|
+
elif system == "Linux":
|
|
22
|
+
# /tmp is shared on Linux: one user's lock would block another's tray.
|
|
23
|
+
if env.get("XDG_RUNTIME_DIR"):
|
|
24
|
+
return os.path.join(env["XDG_RUNTIME_DIR"], "cdx-tray", "companion.pid")
|
|
25
|
+
base = env.get("TMPDIR") or tempfile.gettempdir()
|
|
26
|
+
return os.path.join(base, f"cdx-tray-{os.getuid()}", "companion.pid")
|
|
18
27
|
else:
|
|
19
28
|
base = env.get("TMPDIR") or tempfile.gettempdir()
|
|
20
29
|
return os.path.join(base, "cdx-tray", "companion.pid")
|
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:
|
|
@@ -140,11 +140,14 @@ def _build_python_step(target_version):
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def _build_npm_step(target_version):
|
|
143
|
+
def _build_npm_step(target_version, env=None):
|
|
144
144
|
spec = f"cdx-manager@{target_version}" if target_version else "cdx-manager@latest"
|
|
145
|
+
# Resolved here because Windows ships npm as npm.cmd, which a shell-less
|
|
146
|
+
# spawn of "npm" never finds.
|
|
147
|
+
npm = shutil.which("npm", path=(env or os.environ).get("PATH")) or "npm"
|
|
145
148
|
return {
|
|
146
149
|
"label": "npm global upgrade",
|
|
147
|
-
"command": [
|
|
150
|
+
"command": [npm, "install", "-g", spec],
|
|
148
151
|
"cwd": None,
|
|
149
152
|
"env": {},
|
|
150
153
|
}
|
|
@@ -162,7 +165,7 @@ def build_update_plan(target_version=None, package_root=None, env=None, prefix=N
|
|
|
162
165
|
elif mode == "python":
|
|
163
166
|
steps = [_build_python_step(version)]
|
|
164
167
|
elif mode == "npm":
|
|
165
|
-
steps = [_build_npm_step(version)]
|
|
168
|
+
steps = [_build_npm_step(version, env)]
|
|
166
169
|
else:
|
|
167
170
|
raise CdxError(
|
|
168
171
|
"Unable to determine how cdx-manager was installed. "
|
|
@@ -195,7 +198,10 @@ def run_update_plan(plan, runner=None, env=None):
|
|
|
195
198
|
for step in plan["steps"]:
|
|
196
199
|
step_env = {**(env or os.environ), **(step.get("env") or {})}
|
|
197
200
|
kwargs = {"cwd": step.get("cwd"), "env": step_env, "check": False}
|
|
198
|
-
|
|
201
|
+
try:
|
|
202
|
+
result = runner(step["command"], **kwargs)
|
|
203
|
+
except OSError as error:
|
|
204
|
+
result = {"returncode": None, "stdout": "", "stderr": f"could not run {step['command'][0]}: {error}"}
|
|
199
205
|
code = _result_code(result)
|
|
200
206
|
results.append({
|
|
201
207
|
"label": step["label"],
|
|
@@ -216,7 +222,11 @@ def _version_check_env(env):
|
|
|
216
222
|
source = env or os.environ
|
|
217
223
|
allowed = {
|
|
218
224
|
key: source[key]
|
|
219
|
-
|
|
225
|
+
# The home variables let the child find its home directory: without
|
|
226
|
+
# USERPROFILE, Windows Python's Path.home() raises and `cdx -v` prints
|
|
227
|
+
# a traceback instead of the version.
|
|
228
|
+
for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR",
|
|
229
|
+
"HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CDX_HOME")
|
|
220
230
|
if source.get(key)
|
|
221
231
|
}
|
|
222
232
|
return allowed
|
|
@@ -244,7 +254,7 @@ def verify_updated_command(target_version, runner=None, env=None):
|
|
|
244
254
|
env=_version_check_env(env),
|
|
245
255
|
check=False,
|
|
246
256
|
capture_output=True,
|
|
247
|
-
text=True,
|
|
257
|
+
text=True, encoding="utf-8", errors="replace",
|
|
248
258
|
)
|
|
249
259
|
except OSError as error:
|
|
250
260
|
return {
|