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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_22_0.md +25 -0
- package/changelogs/CHANGELOGS_0_23_0.md +19 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +22 -11
- package/docs/operations.md +79 -0
- package/docs/sessions-and-notifications.md +1 -1
- package/docs/technical-overview.md +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +22 -6
- package/src/cli.py +15 -5
- package/src/cli_args.py +69 -11
- package/src/cli_commands.py +3 -0
- package/src/cli_helpers.py +22 -9
- package/src/commands/launch.py +66 -7
- package/src/commands/maintenance.py +1 -0
- package/src/commands/rescue.py +207 -0
- package/src/commands/runs.py +26 -2
- package/src/commands/status.py +94 -6
- package/src/handoff.py +22 -11
- package/src/interactive_usage.py +20 -5
- package/src/provider_background.py +12 -7
- package/src/provider_runtime.py +61 -3
- package/src/retro.py +131 -0
- package/src/rtk_savings.py +68 -0
- package/src/run_registry.py +78 -3
- 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 +22 -2
- package/src/session_status.py +105 -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_instance.py +11 -2
- package/src/update_manager.py +10 -4
package/src/session_backup.py
CHANGED
|
@@ -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
|
-
|
|
296
|
+
if session_payload["name"] not in existing:
|
|
297
|
+
_validate_new_session_name(session_payload["name"])
|
|
292
298
|
_normalize_provider(session_payload["provider"])
|
|
293
|
-
|
|
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
|
-
|
|
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:
|
package/src/session_helpers.py
CHANGED
|
@@ -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
|
|
package/src/session_ranking.py
CHANGED
|
@@ -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
|
package/src/session_service.py
CHANGED
|
@@ -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),
|
package/src/session_status.py
CHANGED
|
@@ -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
|
-
|
|
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")
|
package/src/status_source.py
CHANGED
|
@@ -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
|
}
|
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_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/update_manager.py
CHANGED
|
@@ -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"],
|