cdx-manager 0.21.6 → 0.23.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.
@@ -106,13 +106,16 @@ def _collect_auth_files(session_root, provider, session_name=None, progress_call
106
106
  })
107
107
  return {"files": files, "file_count": len(files), "bytes": total_bytes}
108
108
 
109
- def _validate_import_profile_files(payload, imported_sessions):
109
+ def _validate_import_profile_files(payload, imported_sessions, include_auth=True):
110
110
  profiles = payload.get("profiles") or {}
111
111
  if not isinstance(profiles, dict):
112
112
  raise CdxError("Bundle contains invalid profile data.")
113
+ if any(profiles.values()) and not include_auth:
114
+ raise CdxError("Bundle contains profile files but no auth payload.")
113
115
  decoded_profiles = {}
114
116
  for session_payload in imported_sessions:
115
117
  name = session_payload["name"]
118
+ allowed_paths = _auth_bundle_paths(_normalize_provider(session_payload["provider"]))
116
119
  files = []
117
120
  profile_items = profiles.get(name, [])
118
121
  if not isinstance(profile_items, list):
@@ -121,6 +124,8 @@ def _validate_import_profile_files(payload, imported_sessions):
121
124
  if not isinstance(item, dict):
122
125
  raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
123
126
  rel_path = _safe_relpath(item.get("path"))
127
+ if rel_path not in allowed_paths:
128
+ raise CdxError(f"Bundle contains an unexpected file for session {name}: {rel_path}")
124
129
  data_b64 = item.get("data_b64")
125
130
  if not isinstance(data_b64, str):
126
131
  raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
@@ -288,9 +293,14 @@ def import_bundle(base_dir, store, file_path,
288
293
  )
289
294
 
290
295
  for session_payload in imported_sessions:
291
- _validate_new_session_name(session_payload["name"])
296
+ if session_payload["name"] not in existing:
297
+ _validate_new_session_name(session_payload["name"])
292
298
  _normalize_provider(session_payload["provider"])
293
- decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
299
+ # A rollback removes the profile folder, so never adopt one we did not create.
300
+ if session_payload["name"] not in existing and os.path.lexists(_get_session_root(base_dir, session_payload["name"])):
301
+ raise CdxError(f"Session profile already exists: {session_payload['name']}")
302
+ decoded_profiles = _validate_import_profile_files(payload, imported_sessions,
303
+ include_auth=bool(decoded["meta"].get("include_auth")))
294
304
 
295
305
  for session_payload in imported_sessions:
296
306
  name = session_payload["name"]
@@ -318,7 +328,8 @@ def import_bundle(base_dir, store, file_path,
318
328
  bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
319
329
  item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
320
330
  )
321
- if bundle_carries_credential:
331
+ # Force replaces the login even when the bundle has none to offer.
332
+ if bundle_carries_credential or (force and provider == PROVIDER_CLAUDE):
322
333
  local_credential = read_keychain_credentials(old_record["authHome"])
323
334
  recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
324
335
  backup_root = os.path.join(recovery_dir, "profile")
@@ -405,7 +416,7 @@ def import_bundle(base_dir, store, file_path,
405
416
  # Decrypted credentials: 0o600 from creation, no umask window.
406
417
  atomic_write(dest_path, item["content"], mode=0o600)
407
418
  imported_paths.append(item["path"])
408
- if provider == PROVIDER_CLAUDE and CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths:
419
+ if provider == PROVIDER_CLAUDE and (force and old_record or CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths):
409
420
  delete_keychain_credentials(auth_home)
410
421
  _restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
411
422
  except Exception:
@@ -8,6 +8,7 @@ them there keep working.
8
8
  """
9
9
 
10
10
  import os
11
+ import re
11
12
  import sys
12
13
  from datetime import datetime
13
14
  from urllib.parse import quote
@@ -150,6 +151,9 @@ def _validate_new_session_name(name):
150
151
  raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
151
152
  if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
152
153
  raise CdxError("Session name cannot contain control characters")
154
+ # Tray launchers and shell hints pass the name to a shell: keep it inert.
155
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", str(name)):
156
+ raise CdxError("Session name may only contain letters, digits, '.', '_' and '-', and must start with a letter or digit")
153
157
  if name in RESERVED_SESSION_NAMES:
154
158
  raise CdxError(f"Session name is reserved: {name}")
155
159
 
@@ -71,6 +71,8 @@ def reasoning_rank(value):
71
71
 
72
72
 
73
73
  def _is_usable_now(row):
74
+ if row.get("rate_limit_reached"):
75
+ return False
74
76
  available = row.get("available_pct")
75
77
  return available is not None and available > USABLE_AVAILABLE_THRESHOLD
76
78
 
@@ -164,6 +166,10 @@ def candidate_rows(rows, provider=None, require_ready=False, min_reasoning_effor
164
166
  for row in rows:
165
167
  if row.get("enabled", True) is False:
166
168
  continue
169
+ if row.get("policy") in ("ask-first", "never"):
170
+ # Never picked on the user's behalf: `ask-first` is still offered
171
+ # where a person confirms (cdx rescue), `never` is not offered at all.
172
+ continue
167
173
  if provider is not None and row.get("provider") != provider:
168
174
  continue
169
175
  if _row_is_logged_out(row):
@@ -199,6 +205,10 @@ def _row_is_ready(row):
199
205
 
200
206
 
201
207
  def _row_has_availability(row):
208
+ if row.get("rate_limit_reached"):
209
+ # The provider says this account cannot serve a task, whatever the
210
+ # percentage windows read.
211
+ return False
202
212
  available = row.get("available_pct")
203
213
  if available is None:
204
214
  # Unknown is not the same as empty. Excluding it here would make a
@@ -130,6 +130,7 @@ LAUNCH_REASONING_EFFORT_VALUES = REASONING_EFFORT_VALUES
130
130
  LAUNCH_PERMISSION_VALUES = PERMISSION_VALUES
131
131
  MAX_LAUNCH_MODEL_LENGTH = 128
132
132
  MIN_LAUNCH_PRIORITY = 0
133
+ ACCOUNT_POLICIES = ("use", "ask-first", "never")
133
134
  MAX_LAUNCH_PRIORITY = 100
134
135
  DEFAULT_LAUNCH_SETTINGS = {
135
136
  "power": "medium",
@@ -147,7 +148,7 @@ def _seed_codex_auth_from_global(auth_home, env=None):
147
148
  return True
148
149
 
149
150
 
150
- _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview", "scrub_env": "scrub env"}
151
+ _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview", "scrub_env": "scrub env", "shared_memory": "shared memory", "retro": "retro"}
151
152
 
152
153
 
153
154
  def _has_control_chars(text):
@@ -235,6 +236,10 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
235
236
  if priority < MIN_LAUNCH_PRIORITY or priority > MAX_LAUNCH_PRIORITY:
236
237
  raise CdxError(f"Unsupported priority: {settings['priority']}")
237
238
  normalized["priority"] = priority
239
+ if "policy" in settings and settings["policy"] is not None:
240
+ if settings["policy"] not in ACCOUNT_POLICIES:
241
+ raise CdxError(f"Unsupported policy: {settings['policy']} (use|ask-first|never)")
242
+ normalized["policy"] = settings["policy"]
238
243
  return normalized
239
244
 
240
245
 
@@ -319,7 +324,7 @@ def unset_launch_settings(store, name, keys):
319
324
  raise CdxError("At least one launch setting is required.")
320
325
  allowed = {
321
326
  "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
322
- "scrub_env", "model", "fallback_model", "budget", "extra_args", "priority",
327
+ "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy",
323
328
  }
324
329
  unknown = [key for key in keys if key not in allowed]
325
330
  if unknown:
@@ -626,6 +631,10 @@ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
626
631
  _validate_new_session_name(name)
627
632
  normalized_provider = _normalize_provider(provider)
628
633
  session_root = _get_session_root(base_dir, name)
634
+ if store["get_session"](name):
635
+ raise CdxError(f"Session already exists: {name}")
636
+ if os.path.lexists(session_root):
637
+ raise CdxError(f"Session profile already exists: {name}")
629
638
  auth_home = _get_session_auth_home(base_dir, name, normalized_provider)
630
639
  _ensure_private_dir(base_dir)
631
640
  _ensure_private_dir(os.path.join(base_dir, "profiles"))
@@ -922,6 +931,16 @@ def begin_conversation(store, name):
922
931
  })
923
932
 
924
933
 
934
+ def mark_conversation_rescued(store, name, conversation_id):
935
+ """Stamp the source of a rescue handoff so the same conversation is not handed off twice."""
936
+ def updater(s):
937
+ conversation = s.get("conversation") or {}
938
+ if conversation.get("id") != conversation_id:
939
+ return s
940
+ return {**s, "conversation": {**conversation, "rescuedAt": _local_now_iso()}}
941
+ return store["update_session"](name, updater)
942
+
943
+
925
944
  def get_session_root(base_dir, name):
926
945
  return _get_session_root(base_dir, name)
927
946
 
@@ -960,6 +979,7 @@ def create_session_service(options=None):
960
979
  "rename_session": partial(rename_session, base_dir, store),
961
980
  "launch_session": partial(launch_session, store),
962
981
  "begin_conversation": partial(begin_conversation, store),
982
+ "mark_conversation_rescued": partial(mark_conversation_rescued, store),
963
983
  "active_session_runtime": partial(_session_runtime, store),
964
984
  "start_session_runtime": partial(start_session_runtime, store),
965
985
  "finish_session_runtime": partial(finish_session_runtime, store),
@@ -7,7 +7,7 @@ coupling measurement supports. Moved verbatim; re-exported by session_service.
7
7
  import json
8
8
  import os
9
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
10
- from datetime import datetime, timezone
10
+ from datetime import datetime, timedelta, timezone
11
11
 
12
12
  from .claude_credentials import claude_auth_type, setup_token_days_left
13
13
  from .claude_usage import _decode_jwt_claims
@@ -41,6 +41,79 @@ def _to_local_iso(value):
41
41
  parsed = parsed.replace(tzinfo=timezone.utc)
42
42
  return parsed.astimezone().isoformat()
43
43
 
44
+ _MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
45
+
46
+
47
+ def reset_iso(display, reference=None):
48
+ """`Oct 20 15:00` (local time, no year) as ISO 8601, or None.
49
+
50
+ Every status source stores resets in that display shape. The year is the
51
+ one that puts the reset closest to `reference` (when the status was read),
52
+ so a January reset read in late December lands in the next year. A reset
53
+ with no time of day stays a date.
54
+ """
55
+ parts = str(display or "").split()
56
+ if len(parts) not in (2, 3) or parts[0] not in _MONTHS or not parts[1].isdigit():
57
+ return None
58
+ try:
59
+ hour, minute = (int(v) for v in parts[2].split(":")) if len(parts) == 3 else (0, 0)
60
+ now = datetime.fromisoformat(str(reference)) if reference else datetime.now().astimezone()
61
+ if now.tzinfo is None:
62
+ now = now.astimezone()
63
+ month, day = _MONTHS.index(parts[0]) + 1, int(parts[1])
64
+ candidates = []
65
+ for year in (now.year - 1, now.year, now.year + 1):
66
+ try:
67
+ # Naive local time resolved by the system zone, so a reset on
68
+ # the other side of a DST change gets its own offset.
69
+ candidates.append(datetime(year, month, day, hour, minute).astimezone())
70
+ except (ValueError, OverflowError, OSError):
71
+ # Windows cannot place a time before 1970 in the local zone.
72
+ continue
73
+ except (TypeError, ValueError):
74
+ return None
75
+ if not candidates:
76
+ return None
77
+ best = min(candidates, key=lambda value: abs(value - now))
78
+ return best.date().isoformat() if len(parts) == 2 else best.isoformat()
79
+
80
+
81
+ _WINDOWS = {"5h": timedelta(hours=5), "week": timedelta(days=7)}
82
+
83
+
84
+ def runs_out_at(remaining_pct, reset_at_iso, window, read_at_iso):
85
+ """When the account empties at the pace it used since its window opened, or None.
86
+
87
+ The window opened `window` before its reset; what was used since then, over
88
+ the time elapsed at the reading, is the pace. None when that pace does not
89
+ empty the account before the window resets, or when a figure is missing.
90
+ """
91
+ try:
92
+ reset = datetime.fromisoformat(str(reset_at_iso))
93
+ read = datetime.fromisoformat(str(read_at_iso))
94
+ remaining = float(remaining_pct)
95
+ except (TypeError, ValueError):
96
+ return None
97
+ if reset.tzinfo is None or read.tzinfo is None or len(str(reset_at_iso)) <= 10:
98
+ return None
99
+ elapsed = (read - (reset - window)).total_seconds()
100
+ used = 100.0 - remaining
101
+ if remaining <= 0 or used <= 0 or elapsed <= 0:
102
+ return None
103
+ out = read + timedelta(seconds=remaining * elapsed / used)
104
+ return out.isoformat() if out < reset else None
105
+
106
+
107
+ def _runs_out(row):
108
+ """The earlier of the two windows' run-out times."""
109
+ times = [
110
+ runs_out_at(row.get(f"remaining_{name}_pct"), row.get(f"reset_{name}_at_iso"), window, row.get("updated_at"))
111
+ for name, window in _WINDOWS.items()
112
+ ]
113
+ times = [value for value in times if value]
114
+ return min(times, key=datetime.fromisoformat) if times else None
115
+
116
+
44
117
  def _row_priority(session):
45
118
  launch = session.get("launch") or {}
46
119
  try:
@@ -265,13 +338,32 @@ def _fetch_codex_status(codex_status_fetcher, custom_codex_status_fetcher, defau
265
338
  timeout = timeout_seconds or default_status_timeout_seconds
266
339
  return codex_status_fetcher(session, timeout=timeout)
267
340
 
341
+ def session_paths(session):
342
+ """Where a session's files live, so a caller never rebuilds cdx's layout.
343
+
344
+ `profile_dir` is the session's own directory and `transcripts_dir` the
345
+ provider's conversation store inside it (None for a provider cdx does not
346
+ read transcripts from). The credential home itself is deliberately not
347
+ named, the same rule `cdx status` rows follow.
348
+ """
349
+ auth_home = session.get("authHome")
350
+ transcripts = {
351
+ PROVIDER_CLAUDE: (".claude", "projects"),
352
+ PROVIDER_CODEX: ("sessions",),
353
+ }.get(session.get("provider"))
354
+ return {
355
+ "profile_dir": session.get("sessionRoot") or auth_home,
356
+ "transcripts_dir": os.path.join(auth_home, *transcripts) if auth_home and transcripts else None,
357
+ }
358
+
359
+
268
360
  def _status_row_from_session(store, s):
269
361
  status = s.get("lastStatus")
270
362
  enabled = s.get("enabled", True) is not False
271
363
  row_status = status if enabled else None
272
364
  runtime = _session_runtime(store, s["name"])
273
365
  auth_type = claude_auth_type(s["authHome"]) if s["provider"] == PROVIDER_CLAUDE and s.get("authHome") else None
274
- return {
366
+ row = {
275
367
  "session_name": s["name"],
276
368
  "label": s.get("label"),
277
369
  "provider": s["provider"],
@@ -281,6 +373,7 @@ def _status_row_from_session(store, s):
281
373
  # recommendation path could not see either, which is why `cdx next`
282
374
  # ignored `--priority` entirely.
283
375
  "priority": _row_priority(s),
376
+ "policy": (s.get("launch") or {}).get("policy") or "use",
284
377
  "reasoning_effort": _row_reasoning_effort(s),
285
378
  "active": bool(runtime) if enabled else False,
286
379
  "cwd": runtime.get("cwd") if runtime else None,
@@ -298,9 +391,18 @@ def _status_row_from_session(store, s):
298
391
  "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
299
392
  "reset_week_at": row_status.get("reset_week_at") if row_status else None,
300
393
  "reset_at": row_status.get("reset_at") if row_status else None,
394
+ # The provider's typed block reason (workspace_owner_credits_depleted):
395
+ # failover and ranking read it, since the percentages can look healthy.
396
+ "rate_limit_reached": row_status.get("rate_limit_reached") if row_status else None,
397
+ **{
398
+ f"{key}_iso": reset_iso(row_status.get(key), _to_local_iso(s.get("lastStatusAt"))) if row_status else None
399
+ for key in ("reset_5h_at", "reset_week_at", "reset_at")
400
+ },
301
401
  "updated_at": _to_local_iso(s.get("lastStatusAt")),
302
402
  "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
303
403
  }
404
+ row["runs_out_at_iso"] = _runs_out(row)
405
+ return row
304
406
 
305
407
  def format_list_rows(store):
306
408
  sessions = list_sessions(store)
@@ -387,6 +489,7 @@ def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
387
489
  "reset_5h_at": artifact.get("reset_5h_at"),
388
490
  "reset_week_at": artifact.get("reset_week_at"),
389
491
  "reset_at": artifact.get("reset_at"),
492
+ "rate_limit_reached": artifact.get("rate_limit_reached"),
390
493
  "updated_at": artifact.get("updated_at"),
391
494
  "raw_status_text": artifact.get("raw_status_text"),
392
495
  "source_ref": artifact.get("source_ref"),
@@ -0,0 +1,102 @@
1
+ """One Claude auto-memory per project, shared by the profiles that opt in.
2
+
3
+ Claude Code keeps its auto-memory under the profile home
4
+ (`.claude/projects/<project>/memory`), and cdx gives every profile its own
5
+ home, so a fact learned on one account is unknown on the next. A profile with
6
+ `shared_memory` on gets that directory as a symlink into
7
+ `<cdx home>/shared-memory/<project>`, which every such profile reads and
8
+ writes.
9
+
10
+ Nothing is ever deleted. A profile's existing memory is copied into the store
11
+ and the original directory kept beside it as `memory.pre-cdx-<time>`. Two
12
+ files with the same name and different content are a conflict: the profile
13
+ is left as it was and the caller says which files to merge by hand. The
14
+ `MEMORY.md` index is the one exception, since it is one line per memory: its
15
+ lines are merged, store first.
16
+ """
17
+
18
+ import filecmp
19
+ import os
20
+ import re
21
+ import shutil
22
+ import time
23
+
24
+ INDEX_NAME = "MEMORY.md"
25
+ #: Claude Code shortens longer project folder names with a hash cdx cannot
26
+ #: reproduce, so those projects are left alone rather than guessed at.
27
+ MAX_PROJECT_NAME = 200
28
+
29
+
30
+ def claude_project_name(cwd):
31
+ """The folder name Claude Code files a working directory under."""
32
+ return re.sub(r"[^A-Za-z0-9]", "-", os.path.realpath(cwd))
33
+
34
+
35
+ def store_dir(base_dir, project):
36
+ return os.path.join(base_dir, "shared-memory", project)
37
+
38
+
39
+ def link(auth_home, base_dir, cwd, now=None):
40
+ """Point the profile's memory for `cwd` at the shared store.
41
+
42
+ Returns (status, detail): `linked` (done now or already), `skipped`
43
+ (project name too long, or the memory path is a link cdx did not make),
44
+ `conflict` (detail lists the files), `unsupported` (no symlinks here).
45
+ """
46
+ project = claude_project_name(cwd)
47
+ if len(project) > MAX_PROJECT_NAME:
48
+ return "skipped", "project path too long to match Claude Code's folder name"
49
+ store = store_dir(base_dir, project)
50
+ memory = os.path.join(auth_home, ".claude", "projects", project, "memory")
51
+ if os.path.islink(memory):
52
+ if os.path.realpath(memory) == os.path.realpath(store):
53
+ return "linked", memory
54
+ return "skipped", f"{memory} already links elsewhere"
55
+ os.makedirs(store, exist_ok=True)
56
+ if os.path.isdir(memory):
57
+ conflicts = _conflicts(memory, store)
58
+ if conflicts:
59
+ return "conflict", conflicts
60
+ _merge_into(memory, store)
61
+ stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(time.time() if now is None else now))
62
+ os.rename(memory, f"{memory}.pre-cdx-{stamp}")
63
+ else:
64
+ os.makedirs(os.path.dirname(memory), exist_ok=True)
65
+ try:
66
+ os.symlink(store, memory, target_is_directory=True)
67
+ except (OSError, NotImplementedError) as error:
68
+ return "unsupported", str(error)
69
+ return "linked", memory
70
+
71
+
72
+ def _conflicts(memory, store):
73
+ found = []
74
+ for name in sorted(os.listdir(memory)):
75
+ source, target = os.path.join(memory, name), os.path.join(store, name)
76
+ if not os.path.exists(target) or name == INDEX_NAME:
77
+ continue
78
+ if os.path.isdir(source) or os.path.isdir(target) or not filecmp.cmp(source, target, shallow=False):
79
+ found.append(name)
80
+ return found
81
+
82
+
83
+ def _merge_into(memory, store):
84
+ for name in os.listdir(memory):
85
+ source, target = os.path.join(memory, name), os.path.join(store, name)
86
+ if name == INDEX_NAME and os.path.exists(target):
87
+ _merge_index(source, target)
88
+ elif not os.path.exists(target):
89
+ if os.path.isdir(source):
90
+ shutil.copytree(source, target)
91
+ else:
92
+ shutil.copy2(source, target)
93
+
94
+
95
+ def _merge_index(source, target):
96
+ with open(target, encoding="utf-8") as handle:
97
+ kept = handle.read().splitlines()
98
+ with open(source, encoding="utf-8") as handle:
99
+ added = [line for line in handle.read().splitlines() if line.strip() and line not in kept]
100
+ if added:
101
+ with open(target, "w", encoding="utf-8") as handle:
102
+ handle.write("\n".join(kept + added) + "\n")
@@ -773,6 +773,8 @@ def find_latest_status_artifact(
773
773
  "reset_5h_at": candidate["structured"].get("reset_5h_at"),
774
774
  "reset_week_at": candidate["structured"].get("reset_week_at"),
775
775
  "reset_at": candidate["structured"].get("reset_at"),
776
+ # Never backfilled from an older record: null means not blocked.
777
+ "rate_limit_reached": candidate["structured"].get("rate_limit_reached"),
776
778
  "raw_status_text": candidate["structured"].get("raw_status_text"),
777
779
  "_skip_5h_backfill": candidate["structured"].get("_skip_5h_backfill"),
778
780
  }
@@ -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
 
@@ -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")
@@ -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"],