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
package/src/run_usage.py CHANGED
@@ -14,6 +14,11 @@ The definitions below are therefore normative, not a suggestion:
14
14
  natively; Codex does not, so its cache-inclusive count
15
15
  is reduced here rather than left for a reader to guess.
16
16
  cache_creation_tokens Tokens written to the provider's prompt cache.
17
+ cache_creation_1h_tokens
18
+ The part of cache_creation_tokens written with
19
+ Anthropic's one-hour TTL, which costs 2x instead of
20
+ 1.25x. A subset, never added to a total; absent when
21
+ the provider does not report the split.
17
22
  cache_read_tokens Tokens served from it.
18
23
  cached_input_tokens cache_creation + cache_read. Kept because it is a
19
24
  published field of `cdx run --json`, and because the
@@ -31,8 +36,8 @@ The definitions below are therefore normative, not a suggestion:
31
36
  quantity.
32
37
 
33
38
  Cache creation and cache read are separate fields because they cost very
34
- differently -- 1.25x and 0.1x of uncached input -- and a weighted figure cannot
35
- recover the split once they are summed.
39
+ differently -- 1.25x (2x for a one-hour TTL) and 0.1x of uncached input -- and
40
+ a weighted figure cannot recover the split once they are summed.
36
41
 
37
42
  Absence stays absent: `None` means "not reported", which is not zero. Only
38
43
  `normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
@@ -49,6 +54,7 @@ USAGE_KEYS = (
49
54
  "input_tokens",
50
55
  "cached_input_tokens",
51
56
  "cache_creation_tokens",
57
+ "cache_creation_1h_tokens",
52
58
  "cache_read_tokens",
53
59
  "output_tokens",
54
60
  "reasoning_tokens",
@@ -64,7 +70,7 @@ def empty_usage():
64
70
 
65
71
 
66
72
  def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
67
- output_tokens=None, reasoning_tokens=None):
73
+ output_tokens=None, reasoning_tokens=None, cache_creation_1h_tokens=None):
68
74
  """Build a usage record from the four measured quantities.
69
75
 
70
76
  The derived fields are computed here and only here. A record whose parts are
@@ -80,6 +86,11 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
80
86
  }
81
87
  if all(value is None for value in parts.values()):
82
88
  return empty_usage()
89
+ long_ttl = _non_negative(cache_creation_1h_tokens)
90
+ if long_ttl is not None:
91
+ # A subset of the writes; more than the writes is not a subset.
92
+ long_ttl = min(long_ttl, parts["cache_creation_tokens"] or 0)
93
+ parts["cache_creation_1h_tokens"] = long_ttl
83
94
  cached = _sum_present(parts["cache_creation_tokens"], parts["cache_read_tokens"])
84
95
  total = _sum_present(
85
96
  parts["input_tokens"],
@@ -94,12 +105,13 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
94
105
  #:
95
106
  #: Defaults for rows without an explicit cache-read price. Newer models can
96
107
  #: discount reads further; both stats and currency use the table's rate.
97
- #: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
98
- #: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
99
- #: are a small share of a cache-heavy session, so that error is bounded --
100
- #: stated here rather than hidden behind a single averaged number.
108
+ #: Anthropic's one-hour cache TTL costs 2x instead. Claude transcripts report
109
+ #: that split as `cache_creation.ephemeral_1h_input_tokens`; Claude Code writes
110
+ #: most of its cache that way, so pricing every write at 1.25x understated
111
+ #: them by up to 1.6x. A record without the split keeps the 1.25x rate.
101
112
  CACHE_READ_MULTIPLIER = 0.1
102
113
  CACHE_WRITE_MULTIPLIER = 1.25
114
+ CACHE_WRITE_1H_MULTIPLIER = 2.0
103
115
 
104
116
  #: What an output token costs relative to an uncached input one, when the model
105
117
  #: is unknown. Current model ratios vary, so an unknown model is priced at the
@@ -144,7 +156,8 @@ def weighted_usage(usage, model=None, prices=None):
144
156
  That is not the ranking anyone reading `cdx stats` is looking for.
145
157
 
146
158
  Output and cache-read ratios come from the model that served the run.
147
- Cache writes retain the five-minute TTL assumption.
159
+ Cache writes use the one-hour rate for the part the provider reported as
160
+ one-hour, the five-minute rate for the rest.
148
161
 
149
162
  `reasoning_tokens` is deliberately excluded: Codex reports it as a subset
150
163
  of its output, and counting it would bill those tokens twice.
@@ -154,12 +167,13 @@ def weighted_usage(usage, model=None, prices=None):
154
167
  weights = {
155
168
  "input_tokens": 1.0,
156
169
  "cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
170
+ "cache_creation_1h_tokens": CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER,
157
171
  "cache_read_tokens": cache_read_multiplier(model, prices),
158
172
  "output_tokens": output_multiplier(model, prices),
159
173
  }
160
174
  present = []
161
175
  for key, weight in weights.items():
162
- value = usage.get(key)
176
+ value = _long_ttl_writes(usage) if key == "cache_creation_1h_tokens" else usage.get(key)
163
177
  if value is not None:
164
178
  if weight is None and value:
165
179
  return None
@@ -297,9 +311,20 @@ def estimate_cost(usage, model, prices=None):
297
311
  ]
298
312
  if not present:
299
313
  return None
314
+ long_ttl = _long_ttl_writes(usage) or 0
315
+ present.append(rate["input"] * (CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER) * long_ttl)
300
316
  return sum(present) / 1_000_000
301
317
 
302
318
 
319
+ def _long_ttl_writes(usage):
320
+ """One-hour cache writes, bounded by the writes they are a part of."""
321
+ long_ttl = usage.get("cache_creation_1h_tokens")
322
+ if not isinstance(long_ttl, (int, float)) or isinstance(long_ttl, bool) or long_ttl <= 0:
323
+ return None
324
+ writes = usage.get("cache_creation_tokens")
325
+ return min(long_ttl, writes) if isinstance(writes, (int, float)) and not isinstance(writes, bool) else None
326
+
327
+
303
328
  def claude_usage_dedup_key(record):
304
329
  """What identifies one *billed* Claude API response in a transcript.
305
330
 
@@ -362,6 +387,7 @@ def coerce_usage(usage):
362
387
  cache_read_tokens=usage.get("cache_read_tokens"),
363
388
  output_tokens=usage.get("output_tokens"),
364
389
  reasoning_tokens=usage.get("reasoning_tokens"),
390
+ cache_creation_1h_tokens=usage.get("cache_creation_1h_tokens"),
365
391
  )
366
392
  if record["cached_input_tokens"] is None:
367
393
  # A legacy record carrying only the fused figure: keep it visible rather
@@ -493,6 +519,7 @@ def _usage_from_dict(value):
493
519
  return normalize_usage(
494
520
  input_tokens=input_tokens,
495
521
  cache_creation_tokens=cache_creation,
522
+ cache_creation_1h_tokens=_nested_int(usage, "cache_creation", "ephemeral_1h_input_tokens"),
496
523
  cache_read_tokens=cache_read,
497
524
  output_tokens=_int_value(usage.get("output_tokens"), usage.get("completion_tokens")),
498
525
  reasoning_tokens=_int_value(
@@ -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
@@ -26,7 +26,6 @@ the re-exports as unused and callers importing through this module break.
26
26
  `test_session_service_facade_still_exposes_every_name_its_callers_import`
27
27
  guards that.
28
28
  """
29
- import json
30
29
  import os
31
30
  import shutil
32
31
  import tempfile
@@ -49,8 +48,11 @@ from .config import (
49
48
  normalize_permission,
50
49
  split_extra_args,
51
50
  )
51
+ from .config import (
52
+ parse_on_off as _parse_on_off,
53
+ )
52
54
  from .errors import CdxError
53
- from .fs_utils import atomic_write, remove_tree
55
+ from .fs_utils import remove_tree
54
56
  from .session_backup import ( # noqa: F401 (re-exported for callers and tests)
55
57
  _FORCE_IMPORT_PRESERVED_PROFILE_PATHS,
56
58
  _auth_bundle_paths,
@@ -128,6 +130,7 @@ LAUNCH_REASONING_EFFORT_VALUES = REASONING_EFFORT_VALUES
128
130
  LAUNCH_PERMISSION_VALUES = PERMISSION_VALUES
129
131
  MAX_LAUNCH_MODEL_LENGTH = 128
130
132
  MIN_LAUNCH_PRIORITY = 0
133
+ ACCOUNT_POLICIES = ("use", "ask-first", "never")
131
134
  MAX_LAUNCH_PRIORITY = 100
132
135
  DEFAULT_LAUNCH_SETTINGS = {
133
136
  "power": "medium",
@@ -145,18 +148,7 @@ def _seed_codex_auth_from_global(auth_home, env=None):
145
148
  return True
146
149
 
147
150
 
148
- _ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview"}
149
-
150
-
151
- def _parse_on_off(value, label):
152
- if isinstance(value, bool):
153
- return value
154
- text = str(value).strip().lower()
155
- if text in ("on", "true", "1", "yes"):
156
- return True
157
- if text in ("off", "false", "0", "no"):
158
- return False
159
- raise CdxError(f"Unsupported {label} value: {value}")
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"}
160
152
 
161
153
 
162
154
  def _has_control_chars(text):
@@ -175,7 +167,7 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
175
167
  if "reasoning_effort" in settings and settings["reasoning_effort"] is not None:
176
168
  effort = str(settings["reasoning_effort"]).strip().lower()
177
169
  if effort not in LAUNCH_REASONING_EFFORT_VALUES:
178
- raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}")
170
+ raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}", code="invalid_reasoning_effort")
179
171
  normalized["reasoning_effort"] = effort
180
172
  if "permission" in settings and settings["permission"] is not None:
181
173
  # Accepts the provider-native aliases (workspace-write, read-only,
@@ -244,6 +236,10 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
244
236
  if priority < MIN_LAUNCH_PRIORITY or priority > MAX_LAUNCH_PRIORITY:
245
237
  raise CdxError(f"Unsupported priority: {settings['priority']}")
246
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"]
247
243
  return normalized
248
244
 
249
245
 
@@ -260,24 +256,18 @@ def _parse_status_timeout_seconds(value):
260
256
 
261
257
 
262
258
  def _ensure_claude_attribution_disabled(auth_home):
263
- settings_dir = os.path.join(auth_home, ".claude")
264
- settings_path = os.path.join(settings_dir, "settings.json")
265
- try:
266
- os.makedirs(settings_dir, exist_ok=True)
267
- with open(settings_path, encoding="utf-8") as handle:
268
- settings = json.load(handle)
269
- except FileNotFoundError:
270
- settings = {}
271
- except (OSError, json.JSONDecodeError):
272
- settings = {}
273
- if not isinstance(settings, dict):
274
- settings = {}
275
- settings["includeCoAuthoredBy"] = False
276
- try:
277
- atomic_write(settings_path, json.dumps(settings, indent=2, sort_keys=True) + "\n", mode=0o644)
278
- except OSError:
259
+ # Same reader/writer as the notification hooks: an unparseable file is the
260
+ # user's and is left alone, a symlink stays a symlink, the mode is kept.
261
+ from .agent_notify import _read_json, _write_json
262
+
263
+ settings_path = os.path.join(auth_home, ".claude", "settings.json")
264
+ settings = _read_json(settings_path)
265
+ if settings is None:
279
266
  return False
280
- return True
267
+ if settings.get("includeCoAuthoredBy") is False:
268
+ return True
269
+ settings["includeCoAuthoredBy"] = False
270
+ return _write_json(settings_path, settings)
281
271
 
282
272
 
283
273
  def _normalize_session_label(label):
@@ -334,7 +324,7 @@ def unset_launch_settings(store, name, keys):
334
324
  raise CdxError("At least one launch setting is required.")
335
325
  allowed = {
336
326
  "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
337
- "model", "fallback_model", "budget", "extra_args", "priority",
327
+ "scrub_env", "shared_memory", "retro", "model", "fallback_model", "budget", "extra_args", "priority", "policy",
338
328
  }
339
329
  unknown = [key for key in keys if key not in allowed]
340
330
  if unknown:
@@ -641,6 +631,10 @@ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
641
631
  _validate_new_session_name(name)
642
632
  normalized_provider = _normalize_provider(provider)
643
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}")
644
638
  auth_home = _get_session_auth_home(base_dir, name, normalized_provider)
645
639
  _ensure_private_dir(base_dir)
646
640
  _ensure_private_dir(os.path.join(base_dir, "profiles"))
@@ -868,7 +862,7 @@ def launch_session(store, name, resume=False):
868
862
  if not session:
869
863
  raise CdxError(f"Unknown session: {name}")
870
864
  if session.get("enabled", True) is False:
871
- raise CdxError(f"Session is disabled: {name}")
865
+ raise CdxError(f"Session is disabled: {name}", code="session_disabled")
872
866
  # Reconcile the prior Claude conversation before a fresh launch replaces
873
867
  # its id. This is a single exact transcript lookup, not a global stats scan.
874
868
  backfill_interactive_usage(store, name=name, dry_run=False)
@@ -937,6 +931,16 @@ def begin_conversation(store, name):
937
931
  })
938
932
 
939
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
+
940
944
  def get_session_root(base_dir, name):
941
945
  return _get_session_root(base_dir, name)
942
946
 
@@ -975,6 +979,7 @@ def create_session_service(options=None):
975
979
  "rename_session": partial(rename_session, base_dir, store),
976
980
  "launch_session": partial(launch_session, store),
977
981
  "begin_conversation": partial(begin_conversation, store),
982
+ "mark_conversation_rescued": partial(mark_conversation_rescued, store),
978
983
  "active_session_runtime": partial(_session_runtime, store),
979
984
  "start_session_runtime": partial(start_session_runtime, store),
980
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:
@@ -271,7 +344,7 @@ def _status_row_from_session(store, s):
271
344
  row_status = status if enabled else None
272
345
  runtime = _session_runtime(store, s["name"])
273
346
  auth_type = claude_auth_type(s["authHome"]) if s["provider"] == PROVIDER_CLAUDE and s.get("authHome") else None
274
- return {
347
+ row = {
275
348
  "session_name": s["name"],
276
349
  "label": s.get("label"),
277
350
  "provider": s["provider"],
@@ -281,6 +354,7 @@ def _status_row_from_session(store, s):
281
354
  # recommendation path could not see either, which is why `cdx next`
282
355
  # ignored `--priority` entirely.
283
356
  "priority": _row_priority(s),
357
+ "policy": (s.get("launch") or {}).get("policy") or "use",
284
358
  "reasoning_effort": _row_reasoning_effort(s),
285
359
  "active": bool(runtime) if enabled else False,
286
360
  "cwd": runtime.get("cwd") if runtime else None,
@@ -298,9 +372,18 @@ def _status_row_from_session(store, s):
298
372
  "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
299
373
  "reset_week_at": row_status.get("reset_week_at") if row_status else None,
300
374
  "reset_at": row_status.get("reset_at") if row_status else None,
375
+ # The provider's typed block reason (workspace_owner_credits_depleted):
376
+ # failover and ranking read it, since the percentages can look healthy.
377
+ "rate_limit_reached": row_status.get("rate_limit_reached") if row_status else None,
378
+ **{
379
+ f"{key}_iso": reset_iso(row_status.get(key), _to_local_iso(s.get("lastStatusAt"))) if row_status else None
380
+ for key in ("reset_5h_at", "reset_week_at", "reset_at")
381
+ },
301
382
  "updated_at": _to_local_iso(s.get("lastStatusAt")),
302
383
  "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
303
384
  }
385
+ row["runs_out_at_iso"] = _runs_out(row)
386
+ return row
304
387
 
305
388
  def format_list_rows(store):
306
389
  sessions = list_sessions(store)
@@ -387,6 +470,7 @@ def _resolve_session_status(store, base_dir, env, fetch_codex_status, session,
387
470
  "reset_5h_at": artifact.get("reset_5h_at"),
388
471
  "reset_week_at": artifact.get("reset_week_at"),
389
472
  "reset_at": artifact.get("reset_at"),
473
+ "rate_limit_reached": artifact.get("rate_limit_reached"),
390
474
  "updated_at": artifact.get("updated_at"),
391
475
  "raw_status_text": artifact.get("raw_status_text"),
392
476
  "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
  }