cdx-manager 0.20.1 → 0.20.3
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_20_2.md +12 -0
- package/changelogs/CHANGELOGS_0_20_3.md +23 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +13 -2
- package/src/cli_args.py +10 -6
- package/src/commands/launch.py +39 -4
- package/src/commands/maintenance.py +47 -15
- package/src/commands/status.py +80 -27
- package/src/interactive_usage.py +5 -2
- package/src/provider_runtime.py +32 -4
- package/src/repair.py +18 -0
- package/src/session_service.py +71 -0
- package/src/session_store.py +32 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
10
10
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.3
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# CDX Manager 0.20.2
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Claude transcripts up to 128 MiB are now measured. Before each relaunch, CDX
|
|
6
|
+
reconciles the preceding Claude conversation when its exact transcript was
|
|
7
|
+
not captured; `cdx repair --force` also repairs safely attributable history.
|
|
8
|
+
- Token pricing now ignores synthetic transcript model placeholders when a real
|
|
9
|
+
provider model is present, so measured Claude sessions receive their price.
|
|
10
|
+
- `cdx history` and `cdx stats --since` now include sessions that overlap the
|
|
11
|
+
requested period, including sessions that started before the window and
|
|
12
|
+
completed inside it.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# CDX Manager 0.20.3
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Playwright, npm, pip, and uv now share one cache under `<CDX_HOME>/shared-cache`
|
|
6
|
+
instead of each isolated profile re-downloading its own copy, since profile
|
|
7
|
+
isolation redirects `HOME` per profile and those tools resolve their cache
|
|
8
|
+
dir from it.
|
|
9
|
+
- A session now prints a short line when it ends, cleanly or by Ctrl-C/a
|
|
10
|
+
forwarded signal: the session name, how long it ran, and this run's own
|
|
11
|
+
cost (or token count when the model isn't priced). A genuine provider
|
|
12
|
+
crash still gets no line.
|
|
13
|
+
|
|
14
|
+
## Performance
|
|
15
|
+
|
|
16
|
+
- `cdx status --refresh` is faster: ~2.5s → ~2.2s on a 15-profile fleet,
|
|
17
|
+
verified to return identical data before and after. The Claude auth probe
|
|
18
|
+
now runs concurrently across sessions instead of one at a time, and no
|
|
19
|
+
longer spawns `claude auth status` twice per session (the probe and the
|
|
20
|
+
usage refresh were each independently running the exact same command).
|
|
21
|
+
- `cdx disk profiles --candidates` is faster too: ~5.1s → ~4.6s, same
|
|
22
|
+
before/after verification. Per-profile `du` calls now run concurrently
|
|
23
|
+
instead of one profile at a time.
|
|
@@ -320,6 +320,28 @@
|
|
|
320
320
|
"x86_64-pc-windows-msvc": "e88d296e706ae90624a1f82bf14d36d67787d5f73bbb04986c1ca82293178c68",
|
|
321
321
|
"x86_64-unknown-linux-musl": "cf8ec3f5a56bf50ba42d2dfa44f05ecc4471c5a5a819ca8b5180c6d9eff75ff1"
|
|
322
322
|
}
|
|
323
|
+
},
|
|
324
|
+
"v0.20.1": {
|
|
325
|
+
"github_tarball_sha256": "00922589fbfa5edaee8bff2b9328bd185e91551eb63932fd7e0642a2fd101923",
|
|
326
|
+
"github_zip_sha256": "16aa1e4376368454769150b3adfec146dde5c88ddef8a157c343c00025ed58ce",
|
|
327
|
+
"tray_assets": {
|
|
328
|
+
"aarch64-apple-darwin": "4f73559113b77a67612e47be3cb3d81bfd9cee7b99fd8b5257f01ea6ec1b9d36",
|
|
329
|
+
"aarch64-unknown-linux-musl": "27f3bd4a6c03d164340a753387ac522d43ce0523f70f0251484b2d24a8e504df",
|
|
330
|
+
"x86_64-apple-darwin": "6a11eee823a76e00f02ab1c75c247b079f1e264351f467c41f4aeeb46bc9a3c3",
|
|
331
|
+
"x86_64-pc-windows-msvc": "3513680e96fd5f669c2f2868b3a195899e48ed4d5f3dce080289f90648e60199",
|
|
332
|
+
"x86_64-unknown-linux-musl": "ee86688537638671ceec1d805d23124d8511c11ab62836f14fa2545b418741d2"
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
"v0.20.2": {
|
|
336
|
+
"github_tarball_sha256": "aeadbd180438862b7b1c0cdb31aece5c3ddbfbf2f20631733ffc48e1fd0a9fb7",
|
|
337
|
+
"github_zip_sha256": "79043ee3366f4e3006a51038d011416d510acd29e1cb35038519a3c4ff91571f",
|
|
338
|
+
"tray_assets": {
|
|
339
|
+
"aarch64-apple-darwin": "3da1c5b786fffe757e2d763cf36a5bf6dd9baf6bec06c3f4ba1d521a9919fbe9",
|
|
340
|
+
"aarch64-unknown-linux-musl": "8f9b0181b95d010ea3a13154d0a41fe150e32982847e43a3bba6c5674a8dea6e",
|
|
341
|
+
"x86_64-apple-darwin": "e35682db7adc4c6b4e2a9c112124411726f522423180c3a753bfb8aa7aa1008a",
|
|
342
|
+
"x86_64-pc-windows-msvc": "5ceff82ecd907fda949dfc27835112ad9e3597da78191a0ede4f7d9db82607a8",
|
|
343
|
+
"x86_64-unknown-linux-musl": "db70bb77f08fd954a7378561954d00ad3084b4ada99a4930aa8f021978439e65"
|
|
344
|
+
}
|
|
323
345
|
}
|
|
324
346
|
}
|
|
325
347
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/claude_refresh.py
CHANGED
|
@@ -30,8 +30,9 @@ def _is_stale(session, now=None, ttl_seconds=CLAUDE_REFRESH_TTL_SECONDS):
|
|
|
30
30
|
return (now - updated_at.astimezone(now.tzinfo)).total_seconds() >= ttl_seconds
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=False, ttl_seconds=CLAUDE_REFRESH_TTL_SECONDS):
|
|
33
|
+
def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=False, ttl_seconds=CLAUDE_REFRESH_TTL_SECONDS, already_auth_refreshed=None):
|
|
34
34
|
refresh_fn = refresh_fn or refresh_claude_session_status
|
|
35
|
+
already_auth_refreshed = already_auth_refreshed or set()
|
|
35
36
|
target_names = set(target_names or [])
|
|
36
37
|
sessions = service["list_sessions"]()
|
|
37
38
|
claude_sessions = [
|
|
@@ -52,7 +53,17 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
|
|
|
52
53
|
|
|
53
54
|
def fetch(s):
|
|
54
55
|
try:
|
|
55
|
-
|
|
56
|
+
# The auth-probe phase right before this one already ran
|
|
57
|
+
# `claude auth status` for this session (see
|
|
58
|
+
# commands/status.py:_refresh_claude_auth_states), which is the
|
|
59
|
+
# exact side effect this refresh needs before reading
|
|
60
|
+
# credentials from disk. Skip re-running it - but only when
|
|
61
|
+
# refresh_fn is the real default, since a caller-injected
|
|
62
|
+
# refresh_fn (tests, mocks) may not accept auth_refresher.
|
|
63
|
+
if refresh_fn is refresh_claude_session_status and s["name"] in already_auth_refreshed:
|
|
64
|
+
usage = refresh_fn(s, auth_refresher=lambda _auth_home: None)
|
|
65
|
+
else:
|
|
66
|
+
usage = refresh_fn(s)
|
|
56
67
|
if inspect.isawaitable(usage):
|
|
57
68
|
raise CdxError("Claude refresh function returned an awaitable from a sync callable.")
|
|
58
69
|
if usage:
|
package/src/cli_args.py
CHANGED
|
@@ -745,8 +745,8 @@ def _has_history_period(period):
|
|
|
745
745
|
return period.get("from_ts") is not None or period.get("to_ts") is not None
|
|
746
746
|
|
|
747
747
|
|
|
748
|
-
def _parse_entry_timestamp(entry):
|
|
749
|
-
value = entry.get(
|
|
748
|
+
def _parse_entry_timestamp(entry, field="started_at"):
|
|
749
|
+
value = entry.get(field)
|
|
750
750
|
if not value:
|
|
751
751
|
return None
|
|
752
752
|
text = str(value)
|
|
@@ -768,12 +768,16 @@ def _filter_history_period(entries, period):
|
|
|
768
768
|
start = period.get("from_ts")
|
|
769
769
|
end = period.get("to_ts")
|
|
770
770
|
for entry in entries:
|
|
771
|
-
|
|
772
|
-
if
|
|
771
|
+
started = _parse_entry_timestamp(entry)
|
|
772
|
+
if started is None:
|
|
773
773
|
continue
|
|
774
|
-
|
|
774
|
+
# A long run belongs to a period it overlaps, not only the day it was
|
|
775
|
+
# launched. Usage is recorded per run, so include its whole measured
|
|
776
|
+
# total rather than inventing a time-proportional split.
|
|
777
|
+
ended = _parse_entry_timestamp(entry, "ended_at") or started
|
|
778
|
+
if start is not None and ended < start:
|
|
775
779
|
continue
|
|
776
|
-
if end is not None and
|
|
780
|
+
if end is not None and started > end:
|
|
777
781
|
continue
|
|
778
782
|
filtered.append(entry)
|
|
779
783
|
return filtered
|
package/src/commands/launch.py
CHANGED
|
@@ -26,7 +26,14 @@ from ..config import PROVIDER_CODEX
|
|
|
26
26
|
from ..context_store import install_context_for_session, write_context
|
|
27
27
|
from ..errors import CdxError
|
|
28
28
|
from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
|
|
29
|
-
from ..provider_runtime import
|
|
29
|
+
from ..provider_runtime import (
|
|
30
|
+
INTERRUPT_EXIT_CODES,
|
|
31
|
+
_conversation_id,
|
|
32
|
+
_ensure_session_authentication,
|
|
33
|
+
_get_auth_home,
|
|
34
|
+
_run_interactive_provider_command,
|
|
35
|
+
)
|
|
36
|
+
from ..run_usage import estimate_cost
|
|
30
37
|
|
|
31
38
|
|
|
32
39
|
def _parse_launch_args(args):
|
|
@@ -268,6 +275,8 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
268
275
|
run_info = _attach_interactive_usage(
|
|
269
276
|
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
|
|
270
277
|
)
|
|
278
|
+
if not json_flag and error.exit_code in INTERRUPT_EXIT_CODES:
|
|
279
|
+
ctx["out"](f"{_goodbye_line(session, run_info, ctx['use_color'])}\n")
|
|
271
280
|
if runtime_run_id:
|
|
272
281
|
ctx["service"]["finish_session_runtime"](
|
|
273
282
|
session["name"],
|
|
@@ -283,15 +292,18 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
283
292
|
**run_info,
|
|
284
293
|
})
|
|
285
294
|
raise
|
|
295
|
+
success_run_info = _attach_interactive_usage(
|
|
296
|
+
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
|
|
297
|
+
)
|
|
286
298
|
ctx["service"]["record_launch_history"](session["name"], {
|
|
287
299
|
"status": "success",
|
|
288
300
|
"action": "resume" if resume else "launch",
|
|
289
301
|
"cwd": cwd,
|
|
290
302
|
"exit_code": 0,
|
|
291
|
-
**
|
|
292
|
-
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
|
|
293
|
-
),
|
|
303
|
+
**success_run_info,
|
|
294
304
|
})
|
|
305
|
+
if not json_flag:
|
|
306
|
+
ctx["out"](f"{_goodbye_line(session, success_run_info, ctx['use_color'])}\n")
|
|
295
307
|
if json_flag:
|
|
296
308
|
extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
|
|
297
309
|
if capability:
|
|
@@ -300,6 +312,29 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
300
312
|
return 0
|
|
301
313
|
|
|
302
314
|
|
|
315
|
+
def _goodbye_line(session, run_info, use_color):
|
|
316
|
+
"""One line printed when a session ends: cleanly, or by SIGINT/SIGTERM/SIGHUP.
|
|
317
|
+
|
|
318
|
+
Not printed for a genuine provider crash (a nonzero exit that isn't one
|
|
319
|
+
of those signals) - that path already gets its own error message.
|
|
320
|
+
|
|
321
|
+
Local import: `commands.status` imports `handle_launch` from this module,
|
|
322
|
+
so importing it back at module scope would be circular.
|
|
323
|
+
"""
|
|
324
|
+
from .status import _format_duration_ms, _format_token_count, _format_usd
|
|
325
|
+
|
|
326
|
+
parts = [f"Session {session['name']} ended", _format_duration_ms(run_info.get("duration_ms"))]
|
|
327
|
+
usage = run_info.get("usage")
|
|
328
|
+
cost = estimate_cost(usage, run_info.get("usage_model"))
|
|
329
|
+
if cost is not None:
|
|
330
|
+
parts.append(_format_usd(cost))
|
|
331
|
+
elif isinstance(usage, dict):
|
|
332
|
+
total_tokens = sum(v for v in usage.values() if isinstance(v, (int, float)))
|
|
333
|
+
if total_tokens:
|
|
334
|
+
parts.append(f"{_format_token_count(total_tokens)} tokens")
|
|
335
|
+
return _dim(" · ".join(parts), use_color)
|
|
336
|
+
|
|
337
|
+
|
|
303
338
|
def _attach_interactive_usage(session, run_info, history=None):
|
|
304
339
|
"""Attach this run's own token usage without affecting launch outcome.
|
|
305
340
|
|
|
@@ -9,6 +9,7 @@ import shlex
|
|
|
9
9
|
import shutil
|
|
10
10
|
import subprocess
|
|
11
11
|
import sys
|
|
12
|
+
import threading
|
|
12
13
|
import time
|
|
13
14
|
from datetime import datetime
|
|
14
15
|
|
|
@@ -83,20 +84,35 @@ def _directory_size_bytes(path, runner=None):
|
|
|
83
84
|
return total
|
|
84
85
|
|
|
85
86
|
def _directory_child_sizes(path, runner=None, progress=None):
|
|
86
|
-
rows = []
|
|
87
87
|
try:
|
|
88
88
|
names = os.listdir(path)
|
|
89
89
|
except OSError:
|
|
90
|
-
return
|
|
90
|
+
return []
|
|
91
91
|
children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
|
|
92
|
-
|
|
92
|
+
# `du` per profile is I/O-bound (a subprocess spawn + directory walk), so
|
|
93
|
+
# one profile's measurement doesn't block the next - same reasoning as
|
|
94
|
+
# the auth-probe fix in commands/status.py. Progress is announced from
|
|
95
|
+
# the main thread, in listing order, before dispatch: du itself only
|
|
96
|
+
# runs concurrently, so the printed order still matches `len(children)`.
|
|
97
|
+
sizes = [None] * len(children)
|
|
98
|
+
threads = []
|
|
99
|
+
|
|
100
|
+
def measure(index, child_path):
|
|
101
|
+
sizes[index] = _directory_size_bytes(child_path, runner=runner)
|
|
102
|
+
|
|
103
|
+
for index, (name, child_path) in enumerate(children):
|
|
93
104
|
if progress:
|
|
94
|
-
progress("profile", name, index, len(children))
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
progress("profile", name, index + 1, len(children))
|
|
106
|
+
t = threading.Thread(target=measure, args=(index, child_path), daemon=True)
|
|
107
|
+
threads.append(t)
|
|
108
|
+
t.start()
|
|
109
|
+
for t in threads:
|
|
110
|
+
t.join()
|
|
111
|
+
|
|
112
|
+
rows = [
|
|
113
|
+
{"name": name, "path": child_path, "bytes": sizes[index]}
|
|
114
|
+
for index, (name, child_path) in enumerate(children)
|
|
115
|
+
]
|
|
100
116
|
rows.sort(key=lambda row: row["bytes"], reverse=True)
|
|
101
117
|
for row in rows:
|
|
102
118
|
row["size"] = _format_bytes(row["bytes"])
|
|
@@ -170,11 +186,15 @@ def _collect_old_logs(profile_name, profile_path, days, now=None):
|
|
|
170
186
|
)
|
|
171
187
|
|
|
172
188
|
def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
|
|
173
|
-
candidates = []
|
|
174
189
|
profiles = list(_iter_profile_dirs(base_dir))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
# Same reasoning as _directory_child_sizes: each profile's du calls and
|
|
191
|
+
# log walk are independent I/O, so profiles scan concurrently. Each
|
|
192
|
+
# thread only appends to its own slot in `per_profile`, never a shared list.
|
|
193
|
+
per_profile = [None] * len(profiles)
|
|
194
|
+
threads = []
|
|
195
|
+
|
|
196
|
+
def scan(index, profile_name, profile_path):
|
|
197
|
+
local = []
|
|
178
198
|
tmp_dir = os.path.join(profile_path, ".tmp")
|
|
179
199
|
tmp_targets = [
|
|
180
200
|
("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
|
|
@@ -198,7 +218,7 @@ def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=Non
|
|
|
198
218
|
mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
|
|
199
219
|
except OSError:
|
|
200
220
|
mtime = None
|
|
201
|
-
|
|
221
|
+
local.append(_candidate(
|
|
202
222
|
profile_name,
|
|
203
223
|
kind,
|
|
204
224
|
path,
|
|
@@ -208,7 +228,19 @@ def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=Non
|
|
|
208
228
|
))
|
|
209
229
|
old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
|
|
210
230
|
if old_logs and old_logs["bytes"] > 0:
|
|
211
|
-
|
|
231
|
+
local.append(old_logs)
|
|
232
|
+
per_profile[index] = local
|
|
233
|
+
|
|
234
|
+
for index, (profile_name, profile_path) in enumerate(profiles):
|
|
235
|
+
if progress:
|
|
236
|
+
progress("candidates", profile_name, index + 1, len(profiles))
|
|
237
|
+
t = threading.Thread(target=scan, args=(index, profile_name, profile_path), daemon=True)
|
|
238
|
+
threads.append(t)
|
|
239
|
+
t.start()
|
|
240
|
+
for t in threads:
|
|
241
|
+
t.join()
|
|
242
|
+
|
|
243
|
+
candidates = [item for local in per_profile for item in (local or [])]
|
|
212
244
|
candidates.sort(key=lambda item: item["bytes"], reverse=True)
|
|
213
245
|
return candidates
|
|
214
246
|
|
package/src/commands/status.py
CHANGED
|
@@ -4,6 +4,7 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
import threading
|
|
7
8
|
import time
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
|
|
@@ -40,7 +41,14 @@ from ..cli_render import _dim, _info, _pad_table, _style, _warn
|
|
|
40
41
|
from ..commands.launch import handle_launch
|
|
41
42
|
from ..config import PROVIDER_CLAUDE
|
|
42
43
|
from ..errors import CdxError
|
|
43
|
-
from ..provider_runtime import
|
|
44
|
+
from ..provider_runtime import (
|
|
45
|
+
AUTH_PROBE_AUTHENTICATED,
|
|
46
|
+
AUTH_PROBE_DEGRADED,
|
|
47
|
+
AUTH_PROBE_TIMEOUT_SECONDS,
|
|
48
|
+
_get_auth_home,
|
|
49
|
+
_has_local_claude_auth,
|
|
50
|
+
_probe_provider_auth_status,
|
|
51
|
+
)
|
|
44
52
|
from ..run_usage import (
|
|
45
53
|
USAGE_KEYS,
|
|
46
54
|
estimate_cost,
|
|
@@ -692,6 +700,7 @@ def handle_status(rest, ctx):
|
|
|
692
700
|
ctx.get("refresh_fn"),
|
|
693
701
|
target_names=args if len(args) == 1 else None,
|
|
694
702
|
force=parsed["refresh"],
|
|
703
|
+
already_auth_refreshed=auth_refresh.get("probed"),
|
|
695
704
|
)
|
|
696
705
|
)
|
|
697
706
|
refresh_errors = [
|
|
@@ -746,40 +755,84 @@ def handle_status(rest, ctx):
|
|
|
746
755
|
|
|
747
756
|
def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
|
|
748
757
|
target_names = set(target_names or [])
|
|
758
|
+
candidates = [
|
|
759
|
+
session for session in service["list_sessions"]()
|
|
760
|
+
if session["provider"] == PROVIDER_CLAUDE
|
|
761
|
+
and session.get("enabled", True) is not False
|
|
762
|
+
and (not target_names or session["name"] in target_names)
|
|
763
|
+
and (session.get("auth") or {}).get("status") in ("authenticated", "logged_out")
|
|
764
|
+
]
|
|
765
|
+
if not candidates:
|
|
766
|
+
return {"updated": [], "errors": [], "probed": set()}
|
|
767
|
+
|
|
749
768
|
errors = []
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
769
|
+
results = {}
|
|
770
|
+
threads = []
|
|
771
|
+
# Sessions without a local credentials file are the ones whose probe
|
|
772
|
+
# below actually spawns `claude auth status` (see _probe_provider_auth_status);
|
|
773
|
+
# the Claude usage refresh right after this call spawns that exact same
|
|
774
|
+
# command again to refresh its own on-disk copy before reading it. Track
|
|
775
|
+
# them so the caller can skip that second, redundant spawn.
|
|
776
|
+
probed = {
|
|
777
|
+
session["name"] for session in candidates
|
|
778
|
+
if not _has_local_claude_auth(_get_auth_home(session))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
# The probe itself is the slow part (a subprocess spawn per session on a
|
|
782
|
+
# cache miss); it doesn't touch shared state, so it's safe to run
|
|
783
|
+
# concurrently, same as the sibling Claude usage refresh right after this
|
|
784
|
+
# call. Side effects (service["update_auth_state"]) still run serially,
|
|
785
|
+
# after every thread has joined.
|
|
786
|
+
def probe(session):
|
|
760
787
|
try:
|
|
761
|
-
|
|
788
|
+
results[session["name"]] = _probe_provider_auth_status(
|
|
762
789
|
session,
|
|
763
790
|
spawn_sync=spawn_sync,
|
|
764
791
|
env_override=env_override,
|
|
765
792
|
)
|
|
766
|
-
if auth_status == AUTH_PROBE_DEGRADED:
|
|
767
|
-
errors.append({
|
|
768
|
-
"session": session["name"],
|
|
769
|
-
"error": "Auth probe timed out; authentication status is degraded.",
|
|
770
|
-
})
|
|
771
|
-
continue
|
|
772
|
-
now = _local_now_iso()
|
|
773
|
-
service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
|
|
774
|
-
**auth,
|
|
775
|
-
"status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
|
|
776
|
-
"lastCheckedAt": now,
|
|
777
|
-
**({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
|
|
778
|
-
})
|
|
779
|
-
updated.append(session["name"])
|
|
780
793
|
except Exception as error:
|
|
781
794
|
errors.append({"session": session["name"], "error": str(error)})
|
|
782
|
-
|
|
795
|
+
|
|
796
|
+
for session in candidates:
|
|
797
|
+
t = threading.Thread(target=probe, args=(session,), daemon=True)
|
|
798
|
+
threads.append(t)
|
|
799
|
+
t.start()
|
|
800
|
+
for t in threads:
|
|
801
|
+
t.join(timeout=AUTH_PROBE_TIMEOUT_SECONDS + 1)
|
|
802
|
+
|
|
803
|
+
results = dict(results)
|
|
804
|
+
errors = list(errors)
|
|
805
|
+
|
|
806
|
+
updated = []
|
|
807
|
+
now = _local_now_iso()
|
|
808
|
+
for session in candidates:
|
|
809
|
+
auth_status = results.get(session["name"])
|
|
810
|
+
if auth_status is None:
|
|
811
|
+
continue
|
|
812
|
+
if auth_status == AUTH_PROBE_DEGRADED:
|
|
813
|
+
errors.append({
|
|
814
|
+
"session": session["name"],
|
|
815
|
+
"error": "Auth probe timed out; authentication status is degraded.",
|
|
816
|
+
})
|
|
817
|
+
# A timed-out probe never finished running `claude auth status`,
|
|
818
|
+
# so its on-disk credentials aren't necessarily fresh - don't
|
|
819
|
+
# tell the caller this session's auth refresh can be skipped.
|
|
820
|
+
probed.discard(session["name"])
|
|
821
|
+
continue
|
|
822
|
+
service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
|
|
823
|
+
**auth,
|
|
824
|
+
"status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
|
|
825
|
+
"lastCheckedAt": now,
|
|
826
|
+
**({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
|
|
827
|
+
})
|
|
828
|
+
updated.append(session["name"])
|
|
829
|
+
for session in candidates:
|
|
830
|
+
if session["name"] not in results:
|
|
831
|
+
# Probe raised/never completed for this session (exception or a
|
|
832
|
+
# join() that outran an unresponsive thread); same reasoning as
|
|
833
|
+
# the degraded case above.
|
|
834
|
+
probed.discard(session["name"])
|
|
835
|
+
return {"updated": updated, "errors": errors, "probed": probed}
|
|
783
836
|
|
|
784
837
|
def _rows_by_session(rows):
|
|
785
838
|
return {
|
package/src/interactive_usage.py
CHANGED
|
@@ -22,7 +22,10 @@ import time
|
|
|
22
22
|
from .run_usage import claude_usage_dedup_key, normalize_usage
|
|
23
23
|
from .status_source import _normalize_terminal_transcript
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# Readers stream JSONL line-by-line. A 16 MiB cap excluded ordinary long
|
|
26
|
+
# Claude sessions (for example a five-hour session at 26 MiB) without saving
|
|
27
|
+
# meaningful memory; keep a bounded ceiling for genuinely pathological files.
|
|
28
|
+
MAX_TRANSCRIPT_BYTES = 128 * 1024 * 1024
|
|
26
29
|
MAX_TRANSCRIPT_CANDIDATES = 1000
|
|
27
30
|
MAX_TRANSCRIPT_SCAN_SECONDS = 1
|
|
28
31
|
|
|
@@ -298,7 +301,7 @@ def _claude_usage(handle):
|
|
|
298
301
|
totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
|
|
299
302
|
totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
|
|
300
303
|
totals["output_tokens"] += _number(usage.get("output_tokens"))
|
|
301
|
-
if message.get("model"):
|
|
304
|
+
if isinstance(message.get("model"), str) and not message["model"].startswith("<"):
|
|
302
305
|
model = message["model"]
|
|
303
306
|
except (TypeError, ValueError):
|
|
304
307
|
continue
|
package/src/provider_runtime.py
CHANGED
|
@@ -20,6 +20,7 @@ from .config import (
|
|
|
20
20
|
PROVIDER_CODEX,
|
|
21
21
|
PROVIDER_OLLAMA,
|
|
22
22
|
REASONING_EFFORT_VALUES,
|
|
23
|
+
get_cdx_home,
|
|
23
24
|
split_extra_args,
|
|
24
25
|
)
|
|
25
26
|
from .errors import CdxError
|
|
@@ -158,6 +159,25 @@ def _home_env_overrides(auth_home):
|
|
|
158
159
|
return overrides
|
|
159
160
|
|
|
160
161
|
|
|
162
|
+
def _shared_tool_cache_overrides(env):
|
|
163
|
+
"""Pin tool download/build caches to one shared dir, outside any profile home.
|
|
164
|
+
|
|
165
|
+
Profile isolation redirects HOME to a per-profile auth_home (see
|
|
166
|
+
`_home_env_overrides`), and several dev tools resolve their cache dir from
|
|
167
|
+
HOME. Left alone, that means every profile re-downloads its own copy of
|
|
168
|
+
Playwright's browsers, npm/pip/uv packages, etc. `env` here is the ambient
|
|
169
|
+
env, before HOME gets swapped, so `get_cdx_home(env)` resolves the same
|
|
170
|
+
real per-user location regardless of which profile is launching.
|
|
171
|
+
"""
|
|
172
|
+
shared_cache = os.path.join(get_cdx_home(env), "shared-cache")
|
|
173
|
+
return {
|
|
174
|
+
"PLAYWRIGHT_BROWSERS_PATH": os.path.join(shared_cache, "playwright"),
|
|
175
|
+
"NPM_CONFIG_CACHE": os.path.join(shared_cache, "npm"),
|
|
176
|
+
"PIP_CACHE_DIR": os.path.join(shared_cache, "pip"),
|
|
177
|
+
"UV_CACHE_DIR": os.path.join(shared_cache, "uv"),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
161
181
|
def _anthropic_profile_name():
|
|
162
182
|
return "default"
|
|
163
183
|
|
|
@@ -562,6 +582,8 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
562
582
|
cwd = cwd or os.getcwd()
|
|
563
583
|
env_override = env_override or {}
|
|
564
584
|
env = {**os.environ, **env_override}
|
|
585
|
+
for key, value in _shared_tool_cache_overrides(env).items():
|
|
586
|
+
env.setdefault(key, value)
|
|
565
587
|
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
566
588
|
initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
|
|
567
589
|
_validate_initial_prompt(initial_prompt)
|
|
@@ -1190,11 +1212,17 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
|
|
|
1190
1212
|
) == AUTH_PROBE_AUTHENTICATED
|
|
1191
1213
|
|
|
1192
1214
|
|
|
1215
|
+
_SIGNAL_EXIT_CODES = {signal.SIGINT: 130, signal.SIGTERM: 143}
|
|
1216
|
+
if hasattr(signal, "SIGHUP"):
|
|
1217
|
+
_SIGNAL_EXIT_CODES[signal.SIGHUP] = 129
|
|
1218
|
+
|
|
1219
|
+
# The exit codes above, on their own: what a caller checks to tell "the user
|
|
1220
|
+
# killed this session" apart from "the provider process crashed on its own".
|
|
1221
|
+
INTERRUPT_EXIT_CODES = frozenset(_SIGNAL_EXIT_CODES.values())
|
|
1222
|
+
|
|
1223
|
+
|
|
1193
1224
|
def _signal_exit_code(sig):
|
|
1194
|
-
|
|
1195
|
-
if hasattr(signal, "SIGHUP"):
|
|
1196
|
-
mapping[signal.SIGHUP] = 129
|
|
1197
|
-
return mapping.get(sig, 1)
|
|
1225
|
+
return _SIGNAL_EXIT_CODES.get(sig, 1)
|
|
1198
1226
|
|
|
1199
1227
|
|
|
1200
1228
|
def _signal_name(sig):
|
package/src/repair.py
CHANGED
|
@@ -51,6 +51,24 @@ def repair_health(service, base_dir, env=None, dry_run=True, force=False):
|
|
|
51
51
|
None,
|
|
52
52
|
None if dry_run else "applied",
|
|
53
53
|
))
|
|
54
|
+
backfill = service["backfill_interactive_usage"](dry_run=dry_run)
|
|
55
|
+
if backfill["backfilled"]:
|
|
56
|
+
actions.append(_action(
|
|
57
|
+
"backfill_interactive_usage",
|
|
58
|
+
f"backfill token usage for {backfill['backfilled']} exactly matched Claude launch record"
|
|
59
|
+
f"{'s' if backfill['backfilled'] != 1 else ''} (of {backfill['scanned']} scanned)",
|
|
60
|
+
None,
|
|
61
|
+
None if dry_run else "applied",
|
|
62
|
+
))
|
|
63
|
+
models = service["refresh_interactive_usage_models"](dry_run=dry_run)
|
|
64
|
+
if models["backfilled"]:
|
|
65
|
+
actions.append(_action(
|
|
66
|
+
"refresh_interactive_usage_models",
|
|
67
|
+
f"replace placeholder models on {models['backfilled']} launch record"
|
|
68
|
+
f"{'s' if models['backfilled'] != 1 else ''}",
|
|
69
|
+
None,
|
|
70
|
+
None if dry_run else "applied",
|
|
71
|
+
))
|
|
54
72
|
return {
|
|
55
73
|
"dry_run": dry_run,
|
|
56
74
|
"force": force,
|
package/src/session_service.py
CHANGED
|
@@ -727,6 +727,72 @@ def get_launch_history(store, name=None, limit=20):
|
|
|
727
727
|
return store["list_launch_history"](session_name=name, limit=limit)
|
|
728
728
|
|
|
729
729
|
|
|
730
|
+
def backfill_interactive_usage(store, name=None, dry_run=True):
|
|
731
|
+
"""Recover one safely attributable missing Claude launch per profile."""
|
|
732
|
+
from datetime import datetime
|
|
733
|
+
|
|
734
|
+
from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
|
|
735
|
+
|
|
736
|
+
updates = {}
|
|
737
|
+
sessions = [store["get_session"](name)] if name else store["list_sessions"]()
|
|
738
|
+
for session in sessions:
|
|
739
|
+
if not session:
|
|
740
|
+
continue
|
|
741
|
+
if session.get("provider") != PROVIDER_CLAUDE:
|
|
742
|
+
continue
|
|
743
|
+
conversation = session.get("conversation") or {}
|
|
744
|
+
identifier = conversation.get("id")
|
|
745
|
+
recorded = conversation.get("recordedAt")
|
|
746
|
+
if not identifier or not recorded:
|
|
747
|
+
continue
|
|
748
|
+
usage, path, match, model = extract_interactive_usage(
|
|
749
|
+
PROVIDER_CLAUDE, session.get("authHome"), conversation_id=identifier
|
|
750
|
+
)
|
|
751
|
+
if not usage or match != MATCH_CONVERSATION_ID:
|
|
752
|
+
continue
|
|
753
|
+
history = store["list_launch_history"](session_name=session["name"], limit=0)
|
|
754
|
+
target = next((entry for entry in history if not entry.get("usage") and entry.get("provider_transcript_path") in (None, path)), None)
|
|
755
|
+
if not target or not target.get("started_at"):
|
|
756
|
+
continue
|
|
757
|
+
try:
|
|
758
|
+
delta = abs((datetime.fromisoformat(recorded.replace("Z", "+00:00")) - datetime.fromisoformat(target["started_at"].replace("Z", "+00:00"))).total_seconds())
|
|
759
|
+
except ValueError:
|
|
760
|
+
continue
|
|
761
|
+
if delta > 300:
|
|
762
|
+
continue
|
|
763
|
+
updates[(session["name"], target["started_at"])] = {
|
|
764
|
+
"usage": usage,
|
|
765
|
+
"usage_cumulative": usage,
|
|
766
|
+
"usage_model": model,
|
|
767
|
+
"provider_transcript_path": path,
|
|
768
|
+
"provider_transcript_match": match,
|
|
769
|
+
"usage_backfilled_reason": "exact_current_conversation",
|
|
770
|
+
}
|
|
771
|
+
return store["backfill_launch_usage"](updates, dry_run=dry_run)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def refresh_interactive_usage_models(store, dry_run=True):
|
|
775
|
+
"""Replace a placeholder model with the exact transcript's real model."""
|
|
776
|
+
from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
|
|
777
|
+
|
|
778
|
+
updates = {}
|
|
779
|
+
for session in store["list_sessions"]():
|
|
780
|
+
if session.get("provider") != PROVIDER_CLAUDE:
|
|
781
|
+
continue
|
|
782
|
+
identifier = ((session.get("conversation") or {}).get("id"))
|
|
783
|
+
if not identifier:
|
|
784
|
+
continue
|
|
785
|
+
_usage, path, match, model = extract_interactive_usage(
|
|
786
|
+
PROVIDER_CLAUDE, session.get("authHome"), conversation_id=identifier
|
|
787
|
+
)
|
|
788
|
+
if not path or match != MATCH_CONVERSATION_ID or not model or model.startswith("<"):
|
|
789
|
+
continue
|
|
790
|
+
for entry in store["list_launch_history"](session_name=session["name"], limit=0):
|
|
791
|
+
if entry.get("provider_transcript_path") == path and str(entry.get("usage_model") or "").startswith("<"):
|
|
792
|
+
updates[(session["name"], entry.get("started_at"))] = {"usage_model": model}
|
|
793
|
+
return store["backfill_launch_usage"](updates, dry_run=dry_run)
|
|
794
|
+
|
|
795
|
+
|
|
730
796
|
def start_session_runtime(store, name, payload=None):
|
|
731
797
|
session = store["get_session"](name)
|
|
732
798
|
if not session:
|
|
@@ -803,6 +869,9 @@ def launch_session(store, name, resume=False):
|
|
|
803
869
|
raise CdxError(f"Unknown session: {name}")
|
|
804
870
|
if session.get("enabled", True) is False:
|
|
805
871
|
raise CdxError(f"Session is disabled: {name}")
|
|
872
|
+
# Reconcile the prior Claude conversation before a fresh launch replaces
|
|
873
|
+
# its id. This is a single exact transcript lookup, not a global stats scan.
|
|
874
|
+
backfill_interactive_usage(store, name=name, dry_run=False)
|
|
806
875
|
now = _local_now_iso()
|
|
807
876
|
|
|
808
877
|
def state_updater(state):
|
|
@@ -921,6 +990,8 @@ def create_session_service(options=None):
|
|
|
921
990
|
"record_launch_history": partial(record_launch_history, store),
|
|
922
991
|
"get_launch_history": partial(get_launch_history, store),
|
|
923
992
|
"drop_unvouched_usage": store["drop_unvouched_usage"],
|
|
993
|
+
"backfill_interactive_usage": partial(backfill_interactive_usage, store),
|
|
994
|
+
"refresh_interactive_usage_models": partial(refresh_interactive_usage_models, store),
|
|
924
995
|
"update_auth_state": partial(update_auth_state, store),
|
|
925
996
|
"get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
|
|
926
997
|
"get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
|
package/src/session_store.py
CHANGED
|
@@ -333,6 +333,37 @@ def create_session_store(base_dir):
|
|
|
333
333
|
_fsync_directory(state_dir)
|
|
334
334
|
return {"scanned": scanned, "dropped": dropped, "dry_run": dry_run}
|
|
335
335
|
|
|
336
|
+
def backfill_launch_usage(updates, dry_run=True):
|
|
337
|
+
"""Apply pre-validated usage updates to otherwise empty history rows."""
|
|
338
|
+
with _file_lock(lock_file):
|
|
339
|
+
try:
|
|
340
|
+
with open(launch_history_file, encoding="utf-8") as handle:
|
|
341
|
+
lines = handle.readlines()
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
return {"scanned": 0, "backfilled": 0, "dry_run": dry_run}
|
|
344
|
+
scanned = backfilled = 0
|
|
345
|
+
rewritten = []
|
|
346
|
+
for line in lines:
|
|
347
|
+
stripped = line.strip()
|
|
348
|
+
if not stripped:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
entry = json.loads(stripped)
|
|
352
|
+
except ValueError:
|
|
353
|
+
rewritten.append(line)
|
|
354
|
+
continue
|
|
355
|
+
scanned += 1
|
|
356
|
+
update = updates.get((entry.get("session_name"), entry.get("started_at")))
|
|
357
|
+
if update and (not entry.get("usage") or str(entry.get("usage_model") or "").startswith("<")):
|
|
358
|
+
entry.update(update)
|
|
359
|
+
line = json.dumps(entry, separators=(",", ":")) + "\n"
|
|
360
|
+
backfilled += 1
|
|
361
|
+
rewritten.append(line)
|
|
362
|
+
if not dry_run and backfilled:
|
|
363
|
+
atomic_write(launch_history_file, "".join(rewritten))
|
|
364
|
+
_fsync_directory(state_dir)
|
|
365
|
+
return {"scanned": scanned, "backfilled": backfilled, "dry_run": dry_run}
|
|
366
|
+
|
|
336
367
|
def list_launch_history(session_name=None, limit=20):
|
|
337
368
|
with _file_lock(lock_file):
|
|
338
369
|
try:
|
|
@@ -369,5 +400,6 @@ def create_session_store(base_dir):
|
|
|
369
400
|
"update_session_state": update_session_state,
|
|
370
401
|
"append_launch_history": append_launch_history,
|
|
371
402
|
"drop_unvouched_usage": drop_unvouched_usage,
|
|
403
|
+
"backfill_launch_usage": backfill_launch_usage,
|
|
372
404
|
"list_launch_history": list_launch_history,
|
|
373
405
|
}
|