cdx-manager 0.21.5 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/changelogs/CHANGELOGS_0_22_0.md +25 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +17 -7
- package/docs/operations.md +80 -0
- package/docs/sessions-and-notifications.md +1 -1
- package/docs/technical-overview.md +1 -1
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +23 -7
- package/src/backup_bundle.py +60 -16
- package/src/claude_usage.py +1 -1
- package/src/cli.py +15 -4
- package/src/cli_args.py +76 -19
- package/src/cli_helpers.py +23 -9
- package/src/codex_usage.py +1 -1
- package/src/commands/launch.py +41 -3
- package/src/commands/maintenance.py +1 -0
- package/src/commands/rescue.py +207 -0
- package/src/commands/runs.py +26 -3
- package/src/commands/status.py +56 -6
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +22 -11
- package/src/health.py +2 -2
- package/src/interactive_usage.py +20 -5
- package/src/notify.py +23 -18
- package/src/provider_background.py +13 -8
- package/src/provider_runtime.py +101 -8
- package/src/retro.py +131 -0
- package/src/rtk_savings.py +68 -0
- package/src/run_registry.py +27 -1
- package/src/run_usage.py +36 -9
- package/src/session_backup.py +16 -5
- package/src/session_helpers.py +4 -0
- package/src/session_ranking.py +10 -0
- package/src/session_service.py +39 -34
- package/src/session_status.py +86 -2
- package/src/shared_memory.py +102 -0
- package/src/status_source.py +2 -0
- package/src/status_view.py +23 -23
- package/src/tray_autostart.py +21 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +11 -2
- package/src/tray_logics.py +1 -1
- package/src/update_all.py +1 -1
- package/src/update_check.py +1 -1
- package/src/update_manager.py +17 -7
package/src/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
|
|
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
|
|
98
|
-
#:
|
|
99
|
-
#:
|
|
100
|
-
#:
|
|
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
|
|
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(
|
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
|
@@ -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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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),
|
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:
|
|
@@ -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
|
-
|
|
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")
|
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
|
}
|