cdx-manager 0.20.14 → 0.21.1
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 +28 -1153
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_0.md +17 -0
- package/changelogs/CHANGELOGS_0_21_1.md +6 -0
- package/checksums/release-archives.json +22 -0
- package/docs/assets/icon.png +0 -0
- package/docs/assets/list.png +0 -0
- package/docs/assets/next.png +0 -0
- package/docs/assets/stats.png +0 -0
- package/docs/assets/status.png +0 -0
- package/docs/cli.md +411 -0
- package/docs/features.md +102 -0
- package/docs/getting-started.md +183 -0
- package/docs/operations.md +161 -0
- package/docs/sessions-and-notifications.md +138 -0
- package/docs/technical-overview.md +92 -0
- package/docs/tray-local-testing.md +154 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +1 -1
- package/src/commands/launch.py +44 -9
- package/src/commands/runs.py +38 -16
- package/src/commands/status.py +22 -35
- package/src/context_store.py +2 -1
- package/src/handoff.py +36 -20
- package/src/provider_background.py +6 -1
- package/src/provider_runtime.py +1 -1
- package/src/run_registry.py +7 -7
- package/src/run_usage.py +61 -30
- package/src/session_backup.py +8 -7
- package/src/update_check.py +7 -1
|
@@ -219,13 +219,14 @@ def read_transcript_outcome(path):
|
|
|
219
219
|
records the same facts here. Usage is summed across assistant messages
|
|
220
220
|
because a session can hold several turns.
|
|
221
221
|
"""
|
|
222
|
-
outcome = {"result": None, "usage": None}
|
|
222
|
+
outcome = {"result": None, "usage": None, "usage_model": None}
|
|
223
223
|
if not path:
|
|
224
224
|
return outcome
|
|
225
225
|
totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
|
|
226
226
|
# This path had no de-duplication at all, so a transcript that records the
|
|
227
227
|
# same billed response more than once inflated the total outright.
|
|
228
228
|
counted = set()
|
|
229
|
+
models = set()
|
|
229
230
|
seen = False
|
|
230
231
|
try:
|
|
231
232
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
@@ -261,6 +262,8 @@ def read_transcript_outcome(path):
|
|
|
261
262
|
# interactive one filled the same column under opposite
|
|
262
263
|
# definitions.
|
|
263
264
|
seen = True
|
|
265
|
+
if isinstance(message.get("model"), str) and message["model"]:
|
|
266
|
+
models.add(message["model"])
|
|
264
267
|
totals["input_tokens"] += usage.get("input_tokens") or 0
|
|
265
268
|
totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
|
|
266
269
|
totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
|
|
@@ -269,4 +272,6 @@ def read_transcript_outcome(path):
|
|
|
269
272
|
return outcome
|
|
270
273
|
if seen:
|
|
271
274
|
outcome["usage"] = normalize_usage(**totals)
|
|
275
|
+
if len(models) == 1:
|
|
276
|
+
outcome["usage_model"] = next(iter(models))
|
|
272
277
|
return outcome
|
package/src/provider_runtime.py
CHANGED
|
@@ -515,7 +515,7 @@ def _list_launch_transcript_paths(session, glob_fn=None):
|
|
|
515
515
|
log_dir = _get_launch_transcript_dir(session)
|
|
516
516
|
if not os.path.isdir(log_dir):
|
|
517
517
|
return []
|
|
518
|
-
paths = set(glob_fn(os.path.join(log_dir, "cdx-session*.log")))
|
|
518
|
+
paths = set(glob_fn(os.path.join(glob.escape(log_dir), "cdx-session*.log")))
|
|
519
519
|
legacy = _get_launch_transcript_path(session)
|
|
520
520
|
if os.path.exists(legacy):
|
|
521
521
|
paths.add(legacy)
|
package/src/run_registry.py
CHANGED
|
@@ -28,7 +28,7 @@ REGISTRY_LOCK_TIMEOUT_SECONDS = 120
|
|
|
28
28
|
_LOCK_RETRY_SECONDS = 0.05
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def _acquire_windows_lock(handle, timeout_seconds):
|
|
31
|
+
def _acquire_windows_lock(handle, timeout_seconds, label="run registry"):
|
|
32
32
|
"""Block until the byte is locked, or give up with a nameable error.
|
|
33
33
|
|
|
34
34
|
Uses the non-blocking form and does the waiting here, so the wait is
|
|
@@ -47,14 +47,14 @@ def _acquire_windows_lock(handle, timeout_seconds):
|
|
|
47
47
|
except OSError:
|
|
48
48
|
if time.monotonic() >= deadline:
|
|
49
49
|
raise CdxError(
|
|
50
|
-
"Timed out waiting for the
|
|
50
|
+
f"Timed out waiting for the {label} lock after "
|
|
51
51
|
f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
|
|
52
52
|
75,
|
|
53
53
|
) from None
|
|
54
54
|
time.sleep(_LOCK_RETRY_SECONDS)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def _acquire_posix_lock(handle, timeout_seconds):
|
|
57
|
+
def _acquire_posix_lock(handle, timeout_seconds, label="run registry"):
|
|
58
58
|
"""Acquire a POSIX advisory lock within the same bounded wait as Windows."""
|
|
59
59
|
import fcntl
|
|
60
60
|
|
|
@@ -66,7 +66,7 @@ def _acquire_posix_lock(handle, timeout_seconds):
|
|
|
66
66
|
except OSError:
|
|
67
67
|
if time.monotonic() >= deadline:
|
|
68
68
|
raise CdxError(
|
|
69
|
-
"Timed out waiting for the
|
|
69
|
+
f"Timed out waiting for the {label} lock after "
|
|
70
70
|
f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
|
|
71
71
|
75,
|
|
72
72
|
) from None
|
|
@@ -74,12 +74,12 @@ def _acquire_posix_lock(handle, timeout_seconds):
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
@contextmanager
|
|
77
|
-
def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
77
|
+
def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS, label="run registry"):
|
|
78
78
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
79
79
|
with open(path + ".lock", "a") as handle:
|
|
80
80
|
if sys.platform == "win32":
|
|
81
81
|
import msvcrt
|
|
82
|
-
_acquire_windows_lock(handle, timeout_seconds)
|
|
82
|
+
_acquire_windows_lock(handle, timeout_seconds, label)
|
|
83
83
|
try:
|
|
84
84
|
yield
|
|
85
85
|
finally:
|
|
@@ -87,7 +87,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
|
87
87
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
88
88
|
else:
|
|
89
89
|
import fcntl
|
|
90
|
-
_acquire_posix_lock(handle, timeout_seconds)
|
|
90
|
+
_acquire_posix_lock(handle, timeout_seconds, label)
|
|
91
91
|
try:
|
|
92
92
|
yield
|
|
93
93
|
finally:
|
package/src/run_usage.py
CHANGED
|
@@ -40,6 +40,9 @@ caller can invent a fourth arithmetic.
|
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
import json
|
|
43
|
+
import math
|
|
44
|
+
|
|
45
|
+
from .errors import CdxError
|
|
43
46
|
|
|
44
47
|
#: Order matters only for readability; every consumer addresses these by name.
|
|
45
48
|
USAGE_KEYS = (
|
|
@@ -109,15 +112,19 @@ def output_multiplier(model, prices=None):
|
|
|
109
112
|
entry = (prices if prices is not None else token_prices()[0]).get(model or "")
|
|
110
113
|
if not entry:
|
|
111
114
|
return DEFAULT_OUTPUT_MULTIPLIER
|
|
112
|
-
return entry["output"] / entry["input"]
|
|
115
|
+
return entry["output"] / entry["input"] if entry["input"] else None
|
|
113
116
|
|
|
114
117
|
|
|
115
118
|
def cache_read_multiplier(model, prices=None):
|
|
116
119
|
"""This model's cache-read/input ratio, with the legacy fallback."""
|
|
117
120
|
entry = (prices if prices is not None else token_prices()[0]).get(model or "")
|
|
118
|
-
if not entry
|
|
121
|
+
if not entry:
|
|
119
122
|
return CACHE_READ_MULTIPLIER
|
|
120
|
-
|
|
123
|
+
if not entry["input"]:
|
|
124
|
+
return None
|
|
125
|
+
if "cache_read" not in entry:
|
|
126
|
+
return CACHE_READ_MULTIPLIER
|
|
127
|
+
return entry["cache_read"] / entry["input"] if entry["input"] else None
|
|
121
128
|
|
|
122
129
|
|
|
123
130
|
def weighted_usage(usage, model=None, prices=None):
|
|
@@ -142,11 +149,13 @@ def weighted_usage(usage, model=None, prices=None):
|
|
|
142
149
|
"cache_read_tokens": cache_read_multiplier(model, prices),
|
|
143
150
|
"output_tokens": output_multiplier(model, prices),
|
|
144
151
|
}
|
|
145
|
-
present = [
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if
|
|
149
|
-
|
|
152
|
+
present = []
|
|
153
|
+
for key, weight in weights.items():
|
|
154
|
+
value = usage.get(key)
|
|
155
|
+
if value is not None:
|
|
156
|
+
if weight is None and value:
|
|
157
|
+
return None
|
|
158
|
+
present.append((weight or 0) * value)
|
|
150
159
|
if not present:
|
|
151
160
|
return None
|
|
152
161
|
return int(round(sum(present)))
|
|
@@ -224,17 +233,26 @@ def token_prices(env=None):
|
|
|
224
233
|
if raw:
|
|
225
234
|
try:
|
|
226
235
|
overrides = json.loads(raw)
|
|
227
|
-
except ValueError:
|
|
228
|
-
|
|
229
|
-
if isinstance(overrides, dict):
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
except ValueError as error:
|
|
237
|
+
raise CdxError(f"{TOKEN_PRICES_ENV} must be a JSON object of model rates.") from error
|
|
238
|
+
if not isinstance(overrides, dict):
|
|
239
|
+
raise CdxError(f"{TOKEN_PRICES_ENV} must be a JSON object of model rates.")
|
|
240
|
+
merged = {**DEFAULT_TOKEN_PRICES}
|
|
241
|
+
for name, entry in overrides.items():
|
|
242
|
+
if not isinstance(entry, dict) or not {"input", "output"} <= entry.keys():
|
|
243
|
+
raise CdxError(f"{TOKEN_PRICES_ENV}: {name} requires input and output rates.")
|
|
244
|
+
rates = {}
|
|
245
|
+
for key in ("input", "output", "cache_read"):
|
|
246
|
+
if key not in entry:
|
|
247
|
+
continue
|
|
248
|
+
value = entry[key]
|
|
249
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
250
|
+
value = float("nan")
|
|
251
|
+
if not math.isfinite(value) or value < 0:
|
|
252
|
+
raise CdxError(f"{TOKEN_PRICES_ENV}: {name}.{key} must be a finite nonnegative rate.")
|
|
253
|
+
rates[key] = value
|
|
254
|
+
merged[str(name)] = rates
|
|
255
|
+
return merged, TOKEN_PRICES_ENV
|
|
238
256
|
return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED}"
|
|
239
257
|
|
|
240
258
|
|
|
@@ -245,10 +263,8 @@ def estimate_cost(usage, model, prices=None):
|
|
|
245
263
|
figure. That shortcut was only correct while every model shared one
|
|
246
264
|
output-to-input ratio, and they do not.
|
|
247
265
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
mid-run is priced at the newer one. Upgrade path if that matters: have the
|
|
251
|
-
readers return a per-model breakdown and difference each model separately.
|
|
266
|
+
A run with mixed observed models and no measured per-model breakdown has
|
|
267
|
+
no usage_model, so its usage remains visible but unpriced.
|
|
252
268
|
|
|
253
269
|
An unknown or absent model yields None. Pricing it at a default tier would
|
|
254
270
|
turn "cdx does not know" into a number someone might believe.
|
|
@@ -357,26 +373,41 @@ def _sum_present(*values):
|
|
|
357
373
|
|
|
358
374
|
|
|
359
375
|
def extract_run_usage(provider, stdout_path):
|
|
376
|
+
return extract_run_observation(provider, stdout_path)[0]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def extract_run_observation(provider, stdout_path):
|
|
360
380
|
if not stdout_path or not provider:
|
|
361
|
-
return empty_usage()
|
|
381
|
+
return empty_usage(), None
|
|
362
382
|
if provider not in SUPPORTED_PROVIDERS:
|
|
363
|
-
return empty_usage()
|
|
383
|
+
return empty_usage(), None
|
|
364
384
|
try:
|
|
365
385
|
with open(stdout_path, encoding="utf-8", errors="replace") as handle:
|
|
366
386
|
text = handle.read()
|
|
367
387
|
except OSError:
|
|
368
|
-
return empty_usage()
|
|
388
|
+
return empty_usage(), None
|
|
369
389
|
if not text.strip():
|
|
370
|
-
return empty_usage()
|
|
390
|
+
return empty_usage(), None
|
|
371
391
|
|
|
372
392
|
records = _parse_json_records(text)
|
|
373
393
|
if not records:
|
|
374
|
-
return empty_usage()
|
|
394
|
+
return empty_usage(), None
|
|
375
395
|
|
|
376
396
|
usage = _extract_usage_from_records(records)
|
|
377
397
|
if not _has_usage(usage):
|
|
378
|
-
return empty_usage()
|
|
379
|
-
|
|
398
|
+
return empty_usage(), None
|
|
399
|
+
models = set()
|
|
400
|
+
for record in records:
|
|
401
|
+
if not isinstance(record, dict):
|
|
402
|
+
continue
|
|
403
|
+
message = record.get("message")
|
|
404
|
+
model = record.get("model") or (message.get("model") if isinstance(message, dict) else None)
|
|
405
|
+
if isinstance(model, str) and model and _has_usage(_find_usage(record)):
|
|
406
|
+
models.add(model)
|
|
407
|
+
model_usage = record.get("modelUsage")
|
|
408
|
+
if isinstance(model_usage, dict):
|
|
409
|
+
models.update(model_usage)
|
|
410
|
+
return usage, next(iter(models)) if len(models) == 1 else None
|
|
380
411
|
|
|
381
412
|
|
|
382
413
|
def _parse_json_records(text):
|
package/src/session_backup.py
CHANGED
|
@@ -306,12 +306,20 @@ def import_bundle(base_dir, store, file_path,
|
|
|
306
306
|
old_record = None
|
|
307
307
|
old_state = None
|
|
308
308
|
preserved_profile_paths = []
|
|
309
|
+
# Read the live credential before moving its profile: a denied keychain
|
|
310
|
+
# read must leave the registered session untouched.
|
|
311
|
+
local_credential = None
|
|
309
312
|
if is_existing and (force or merge):
|
|
310
313
|
old_record = store["get_session"](name)
|
|
311
314
|
old_state = store["read_session_state"](name)
|
|
312
315
|
if merge:
|
|
313
316
|
provider = old_record["provider"]
|
|
314
317
|
auth_home = old_record["authHome"]
|
|
318
|
+
bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
|
|
319
|
+
item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
|
|
320
|
+
)
|
|
321
|
+
if bundle_carries_credential:
|
|
322
|
+
local_credential = read_keychain_credentials(old_record["authHome"])
|
|
315
323
|
recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
316
324
|
backup_root = os.path.join(recovery_dir, "profile")
|
|
317
325
|
try:
|
|
@@ -335,13 +343,6 @@ def import_bundle(base_dir, store, file_path,
|
|
|
335
343
|
# bundle credential exists at the credential-backend level and has to be
|
|
336
344
|
# resolved there. Read it strictly: refusing an unreadable keychain is
|
|
337
345
|
# better than importing over a credential we could not preserve.
|
|
338
|
-
bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
|
|
339
|
-
item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
|
|
340
|
-
)
|
|
341
|
-
local_credential = None
|
|
342
|
-
if bundle_carries_credential and old_record is not None:
|
|
343
|
-
local_credential = read_keychain_credentials(auth_home)
|
|
344
|
-
|
|
345
346
|
existing_state_before = None
|
|
346
347
|
try:
|
|
347
348
|
_ensure_private_dir(session_root)
|
package/src/update_check.py
CHANGED
|
@@ -78,7 +78,7 @@ def _fetch_latest_release(env=None):
|
|
|
78
78
|
LATEST_RELEASE_URL,
|
|
79
79
|
headers=headers,
|
|
80
80
|
)
|
|
81
|
-
with urllib.request.urlopen(request, timeout=
|
|
81
|
+
with urllib.request.urlopen(request, timeout=2) as response:
|
|
82
82
|
payload = json.loads(response.read().decode("utf-8"))
|
|
83
83
|
return {
|
|
84
84
|
"latest_version": str(payload.get("tag_name") or "").lstrip("v"),
|
|
@@ -185,6 +185,12 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
|
185
185
|
|
|
186
186
|
latest = fetch_latest_release(env=env)
|
|
187
187
|
if not latest:
|
|
188
|
+
# A failure is cached like a success, keeping the last known version:
|
|
189
|
+
# otherwise a slow network makes every cdx command wait on GitHub again.
|
|
190
|
+
try:
|
|
191
|
+
_write_cache(path, {**cached, "checked_at": now_ts})
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|
|
188
194
|
return None
|
|
189
195
|
|
|
190
196
|
payload = {
|