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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/changelogs/CHANGELOGS_0_21_6.md +33 -0
  4. package/changelogs/CHANGELOGS_0_22_0.md +25 -0
  5. package/checksums/release-archives.json +22 -0
  6. package/docs/cli.md +17 -7
  7. package/docs/operations.md +80 -0
  8. package/docs/sessions-and-notifications.md +1 -1
  9. package/docs/technical-overview.md +1 -1
  10. package/install.ps1 +7 -4
  11. package/package.json +1 -1
  12. package/pyproject.toml +1 -1
  13. package/src/agent_notify.py +23 -7
  14. package/src/backup_bundle.py +60 -16
  15. package/src/claude_usage.py +1 -1
  16. package/src/cli.py +15 -4
  17. package/src/cli_args.py +76 -19
  18. package/src/cli_helpers.py +23 -9
  19. package/src/codex_usage.py +1 -1
  20. package/src/commands/launch.py +41 -3
  21. package/src/commands/maintenance.py +1 -0
  22. package/src/commands/rescue.py +207 -0
  23. package/src/commands/runs.py +26 -3
  24. package/src/commands/status.py +56 -6
  25. package/src/config.py +17 -1
  26. package/src/context_store.py +11 -6
  27. package/src/errors.py +5 -1
  28. package/src/handoff.py +22 -11
  29. package/src/health.py +2 -2
  30. package/src/interactive_usage.py +20 -5
  31. package/src/notify.py +23 -18
  32. package/src/provider_background.py +13 -8
  33. package/src/provider_runtime.py +101 -8
  34. package/src/retro.py +131 -0
  35. package/src/rtk_savings.py +68 -0
  36. package/src/run_registry.py +27 -1
  37. package/src/run_usage.py +36 -9
  38. package/src/session_backup.py +16 -5
  39. package/src/session_helpers.py +4 -0
  40. package/src/session_ranking.py +10 -0
  41. package/src/session_service.py +39 -34
  42. package/src/session_status.py +86 -2
  43. package/src/shared_memory.py +102 -0
  44. package/src/status_source.py +2 -0
  45. package/src/status_view.py +23 -23
  46. package/src/tray_autostart.py +21 -2
  47. package/src/tray_events.py +23 -4
  48. package/src/tray_instance.py +11 -2
  49. package/src/tray_logics.py +1 -1
  50. package/src/update_all.py +1 -1
  51. package/src/update_check.py +1 -1
  52. package/src/update_manager.py +17 -7
@@ -1,4 +1,4 @@
1
- from datetime import datetime, timedelta
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
- # A year-less date nearly a year in the past is a year wrap ("Jan 2"
362
- # rendered on Dec 31), not a reset that passed months ago.
363
- if parsed < now - timedelta(days=182):
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
@@ -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
 
@@ -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 makes this atomic against other hooks firing at the same
134
- # instant, which a read-modify-write of a JSON array would not be.
135
- with open(path, "a", encoding="utf-8") as handle:
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()
@@ -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 temporary directory, then `cdx-tray/companion.pid`.
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
- if platform.system() == "Windows":
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")
@@ -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):
@@ -143,7 +143,7 @@ def _installed_logics_manager_version(env=None, runner=None):
143
143
  result = runner(
144
144
  [executable, "--version"],
145
145
  capture_output=True,
146
- text=True,
146
+ text=True, encoding="utf-8", errors="replace",
147
147
  timeout=2,
148
148
  env=env,
149
149
  )
@@ -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": ["npm", "install", "-g", spec],
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
- result = runner(step["command"], **kwargs)
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
- for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR")
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 {