cdx-manager 0.21.4 → 0.21.6
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_5.md +26 -0
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +3 -3
- package/docs/operations.md +2 -0
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +16 -6
- package/src/backup_bundle.py +60 -16
- package/src/claude_credentials.py +85 -0
- package/src/claude_usage.py +1 -1
- package/src/cli.py +6 -8
- package/src/cli_args.py +43 -15
- package/src/cli_helpers.py +12 -5
- package/src/codex_usage.py +1 -1
- package/src/commands/auth.py +6 -0
- package/src/commands/runs.py +3 -3
- package/src/commands/tray.py +0 -20
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +24 -0
- package/src/health.py +20 -2
- package/src/notify.py +40 -30
- package/src/provider_background.py +1 -1
- package/src/provider_runtime.py +55 -22
- package/src/run_registry.py +19 -7
- package/src/run_usage.py +16 -5
- package/src/session_helpers.py +18 -0
- package/src/session_service.py +34 -92
- package/src/session_status.py +4 -0
- package/src/session_store.py +6 -27
- package/src/status_view.py +18 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +4 -15
- 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 +7 -3
package/src/provider_runtime.py
CHANGED
|
@@ -12,7 +12,7 @@ import uuid
|
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
|
|
14
14
|
from .agent_notify import launch_notify_env, notifications_enabled
|
|
15
|
-
from .claude_credentials import peek_keychain_credentials
|
|
15
|
+
from .claude_credentials import peek_keychain_credentials, read_claude_login_file
|
|
16
16
|
from .claude_usage import _clean_oauth_token, _decode_jwt_claims, _secure_storage_overrides, claude_profile_store_dir
|
|
17
17
|
from .codex_usage import codex_auth_lock, diagnostic_needs_codex_login, fetch_codex_rate_limit_diagnostic
|
|
18
18
|
from .config import (
|
|
@@ -26,7 +26,6 @@ from .config import (
|
|
|
26
26
|
)
|
|
27
27
|
from .errors import CdxError
|
|
28
28
|
|
|
29
|
-
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
30
29
|
TERMINAL_TITLE_SEPARATOR = " — "
|
|
31
30
|
# Providers redraw their own title while their TUI runs, so a single write at
|
|
32
31
|
# launch does not survive. Re-asserting on this interval takes the title back
|
|
@@ -250,12 +249,8 @@ def _read_anthropic_oauth_token(auth_home):
|
|
|
250
249
|
|
|
251
250
|
|
|
252
251
|
def _has_local_claude_auth(auth_home):
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
credentials = json.load(handle)
|
|
256
|
-
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
257
|
-
return False
|
|
258
|
-
oauth = credentials.get("claudeAiOauth") if isinstance(credentials, dict) else None
|
|
252
|
+
credentials = read_claude_login_file(auth_home)
|
|
253
|
+
oauth = credentials.get("claudeAiOauth") if credentials else None
|
|
259
254
|
return bool(isinstance(oauth, dict) and _clean_oauth_token(oauth.get("accessToken")))
|
|
260
255
|
|
|
261
256
|
|
|
@@ -424,11 +419,11 @@ def _normalize_reasoning_effort(reasoning_effort=None, power=None, usage="Unsupp
|
|
|
424
419
|
if alias == "":
|
|
425
420
|
raise CdxError(usage)
|
|
426
421
|
if effort and effort not in REASONING_EFFORT_VALUES:
|
|
427
|
-
raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}")
|
|
422
|
+
raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}", code="invalid_reasoning_effort")
|
|
428
423
|
if alias and alias not in REASONING_EFFORT_VALUES:
|
|
429
424
|
raise CdxError(f"Unsupported power: {power}")
|
|
430
425
|
if effort and alias and effort != alias:
|
|
431
|
-
raise CdxError("--reasoning-effort and --power must match when both are provided.")
|
|
426
|
+
raise CdxError("--reasoning-effort and --power must match when both are provided.", code="invalid_reasoning_effort")
|
|
432
427
|
resolved = effort or alias
|
|
433
428
|
if not resolved:
|
|
434
429
|
return {}
|
|
@@ -539,14 +534,6 @@ def _list_launch_transcript_paths(session, glob_fn=None):
|
|
|
539
534
|
return sorted(paths)
|
|
540
535
|
|
|
541
536
|
|
|
542
|
-
def _rotate_log_if_needed(log_path):
|
|
543
|
-
try:
|
|
544
|
-
if os.path.getsize(log_path) >= LOG_ROTATE_BYTES:
|
|
545
|
-
open(log_path, "w").close()
|
|
546
|
-
except OSError:
|
|
547
|
-
pass
|
|
548
|
-
|
|
549
|
-
|
|
550
537
|
def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=None):
|
|
551
538
|
if not capture_transcript:
|
|
552
539
|
return spec
|
|
@@ -555,7 +542,6 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
|
|
|
555
542
|
script_args = env.get("CDX_SCRIPT_ARGS")
|
|
556
543
|
transcript_path = _build_launch_transcript_path(session)
|
|
557
544
|
os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
|
|
558
|
-
_rotate_log_if_needed(transcript_path)
|
|
559
545
|
if script_args:
|
|
560
546
|
args = shlex.split(script_args)
|
|
561
547
|
if "{transcript}" in args:
|
|
@@ -587,6 +573,19 @@ def _rtk_enabled(session):
|
|
|
587
573
|
return (session.get("launch") or {}).get("rtk") is True
|
|
588
574
|
|
|
589
575
|
|
|
576
|
+
# Claude Code passes credentials (including the setup-token cdx injects) to
|
|
577
|
+
# the Bash commands, hooks and MCP servers it starts unless this is set. Opt-in:
|
|
578
|
+
# it also strips other secrets an agent may need and, on Linux, isolates Bash
|
|
579
|
+
# from host processes (`ps`, `kill`).
|
|
580
|
+
CLAUDE_SCRUB_ENV = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _scrub_env(session):
|
|
584
|
+
if session.get("provider") == PROVIDER_CLAUDE and (session.get("launch") or {}).get("scrub_env") is True:
|
|
585
|
+
return {CLAUDE_SCRUB_ENV: "1"}
|
|
586
|
+
return {}
|
|
587
|
+
|
|
588
|
+
|
|
590
589
|
def _logics_manager_available(path=None):
|
|
591
590
|
return shutil.which("logics-manager", path=path) is not None
|
|
592
591
|
|
|
@@ -676,6 +675,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
676
675
|
for key, value in _shared_tool_cache_overrides(env).items():
|
|
677
676
|
env.setdefault(key, value)
|
|
678
677
|
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
678
|
+
env.update(_scrub_env(session))
|
|
679
679
|
preference_args = _launch_preference_args(session, env=env)
|
|
680
680
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
681
681
|
launch = session.get("launch") or {}
|
|
@@ -929,6 +929,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
929
929
|
# Headless runs read the same home, and so the same hooks, as an interactive
|
|
930
930
|
# launch. Their caller already learns of completion from the return value.
|
|
931
931
|
env.update(launch_notify_env(session, enabled=False))
|
|
932
|
+
env.update(_scrub_env(session))
|
|
932
933
|
preference_args = _launch_preference_args(session, env=env)
|
|
933
934
|
launch = session.get("launch") or {}
|
|
934
935
|
power = _launch_power(session)
|
|
@@ -1070,6 +1071,32 @@ def _signal_child_group(child, sig):
|
|
|
1070
1071
|
return False
|
|
1071
1072
|
|
|
1072
1073
|
|
|
1074
|
+
def _raise_keyboard_interrupt(_signum, _frame):
|
|
1075
|
+
raise KeyboardInterrupt
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _route_termination_to_interrupt():
|
|
1079
|
+
"""Make SIGTERM and SIGHUP raise KeyboardInterrupt; returns what to restore."""
|
|
1080
|
+
previous = {}
|
|
1081
|
+
for name in ("SIGTERM", "SIGHUP"):
|
|
1082
|
+
sig = getattr(signal, name, None)
|
|
1083
|
+
if sig is None:
|
|
1084
|
+
continue
|
|
1085
|
+
try:
|
|
1086
|
+
previous[sig] = signal.signal(sig, _raise_keyboard_interrupt)
|
|
1087
|
+
except (ValueError, OSError): # not the main thread, or not settable here
|
|
1088
|
+
pass
|
|
1089
|
+
return previous
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _restore_signal_handlers(previous):
|
|
1093
|
+
for sig, handler in previous.items():
|
|
1094
|
+
try:
|
|
1095
|
+
signal.signal(sig, handler)
|
|
1096
|
+
except (ValueError, OSError):
|
|
1097
|
+
pass
|
|
1098
|
+
|
|
1099
|
+
|
|
1073
1100
|
def _terminate_child_tree(child):
|
|
1074
1101
|
if not _signal_child_group(child, signal.SIGTERM):
|
|
1075
1102
|
try:
|
|
@@ -1125,14 +1152,18 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1125
1152
|
)
|
|
1126
1153
|
except FileNotFoundError as error:
|
|
1127
1154
|
_combine_headless_transcript(paths)
|
|
1128
|
-
cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127)
|
|
1155
|
+
cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127, code="provider_cli_not_found")
|
|
1129
1156
|
cdx_error.run_info = _headless_run_info(paths, spec, start_time, 127)
|
|
1130
1157
|
raise cdx_error from error
|
|
1131
1158
|
except OSError as error:
|
|
1132
1159
|
_combine_headless_transcript(paths)
|
|
1133
|
-
cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126)
|
|
1160
|
+
cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126, code="provider_start_failed")
|
|
1134
1161
|
cdx_error.run_info = _headless_run_info(paths, spec, start_time, 126)
|
|
1135
1162
|
raise cdx_error from error
|
|
1163
|
+
# SIGTERM/SIGHUP (kill, systemctl stop, a closed SSH session) unwind
|
|
1164
|
+
# like Ctrl+C: the provider's own process group would otherwise outlive
|
|
1165
|
+
# this supervisor and keep its run "running" forever.
|
|
1166
|
+
previous_handlers = _route_termination_to_interrupt()
|
|
1136
1167
|
try:
|
|
1137
1168
|
if timeout_seconds is None:
|
|
1138
1169
|
child.wait()
|
|
@@ -1150,6 +1181,8 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1150
1181
|
# the run still gets a terminal record.
|
|
1151
1182
|
cancelled = True
|
|
1152
1183
|
_terminate_child_tree(child)
|
|
1184
|
+
finally:
|
|
1185
|
+
_restore_signal_handlers(previous_handlers)
|
|
1153
1186
|
|
|
1154
1187
|
if cancelled:
|
|
1155
1188
|
_combine_headless_transcript(paths)
|
|
@@ -1297,7 +1330,7 @@ def _probe_provider_auth_status(session, spawn_sync=None, env_override=None, tru
|
|
|
1297
1330
|
result = subprocess.run(
|
|
1298
1331
|
[command] + spec["args"],
|
|
1299
1332
|
env=spec["env"],
|
|
1300
|
-
capture_output=True, text=True,
|
|
1333
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1301
1334
|
timeout=AUTH_PROBE_TIMEOUT_SECONDS,
|
|
1302
1335
|
)
|
|
1303
1336
|
output = (result.stdout or "") + (result.stderr or "")
|
package/src/run_registry.py
CHANGED
|
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from .errors import CdxError
|
|
9
9
|
from .fs_utils import atomic_write
|
|
10
10
|
from .run_usage import coerce_usage
|
|
11
|
+
from .session_helpers import _process_is_running
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def utc_now_iso():
|
|
@@ -113,13 +114,7 @@ def _write_registry(path, data):
|
|
|
113
114
|
|
|
114
115
|
|
|
115
116
|
def _is_pid_alive(pid):
|
|
116
|
-
|
|
117
|
-
return False
|
|
118
|
-
try:
|
|
119
|
-
os.kill(int(pid), 0)
|
|
120
|
-
return True
|
|
121
|
-
except (OSError, ValueError):
|
|
122
|
-
return False
|
|
117
|
+
return _process_is_running(pid)
|
|
123
118
|
|
|
124
119
|
|
|
125
120
|
def _completed_after(run, since):
|
|
@@ -164,6 +159,22 @@ def _refresh_stale_runs(data):
|
|
|
164
159
|
return changed
|
|
165
160
|
|
|
166
161
|
|
|
162
|
+
# Every update rewrites the whole registry under the lock, so it must not grow
|
|
163
|
+
# with every run ever made. Running runs are always kept; runs are newest first.
|
|
164
|
+
MAX_FINISHED_RUNS = 500
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _prune_finished_runs(data):
|
|
168
|
+
kept, finished = [], 0
|
|
169
|
+
for run in data["runs"]:
|
|
170
|
+
if run.get("status") != "running":
|
|
171
|
+
finished += 1
|
|
172
|
+
if finished > MAX_FINISHED_RUNS:
|
|
173
|
+
continue
|
|
174
|
+
kept.append(run)
|
|
175
|
+
data["runs"] = kept
|
|
176
|
+
|
|
177
|
+
|
|
167
178
|
def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None):
|
|
168
179
|
return {
|
|
169
180
|
"run_id": run_id,
|
|
@@ -218,6 +229,7 @@ class RunRegistry:
|
|
|
218
229
|
data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
|
|
219
230
|
record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
|
|
220
231
|
data["runs"].insert(0, record)
|
|
232
|
+
_prune_finished_runs(data)
|
|
221
233
|
_write_registry(self.path, data)
|
|
222
234
|
return record
|
|
223
235
|
|
package/src/run_usage.py
CHANGED
|
@@ -107,9 +107,17 @@ CACHE_WRITE_MULTIPLIER = 1.25
|
|
|
107
107
|
DEFAULT_OUTPUT_MULTIPLIER = 5.0
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def price_entry(model, prices=None):
|
|
111
|
+
"""This model's rates. Claude Code records a context variant such as
|
|
112
|
+
`claude-opus-5-5[1m]`, which is billed at the base model's rates."""
|
|
113
|
+
table = prices if prices is not None else token_prices()[0]
|
|
114
|
+
model = model or ""
|
|
115
|
+
return table.get(model) or table.get(model.split("[", 1)[0])
|
|
116
|
+
|
|
117
|
+
|
|
110
118
|
def output_multiplier(model, prices=None):
|
|
111
119
|
"""This model's output-to-input price ratio."""
|
|
112
|
-
entry = (
|
|
120
|
+
entry = price_entry(model, prices)
|
|
113
121
|
if not entry:
|
|
114
122
|
return DEFAULT_OUTPUT_MULTIPLIER
|
|
115
123
|
return entry["output"] / entry["input"] if entry["input"] else None
|
|
@@ -117,7 +125,7 @@ def output_multiplier(model, prices=None):
|
|
|
117
125
|
|
|
118
126
|
def cache_read_multiplier(model, prices=None):
|
|
119
127
|
"""This model's cache-read/input ratio, with the legacy fallback."""
|
|
120
|
-
entry = (
|
|
128
|
+
entry = price_entry(model, prices)
|
|
121
129
|
if not entry:
|
|
122
130
|
return CACHE_READ_MULTIPLIER
|
|
123
131
|
if not entry["input"]:
|
|
@@ -185,7 +193,7 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
185
193
|
"claude-fable-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
|
|
186
194
|
"claude-mythos-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
|
|
187
195
|
"claude-opus-5-5": {"input": 4.0, "output": 20.0, "cache_read": 0.2},
|
|
188
|
-
"claude-sonnet-5-5": {"input": 2.0, "output": 10.0},
|
|
196
|
+
"claude-sonnet-5-5": {"input": 2.0, "output": 10.0, "cache_read": 0.1},
|
|
189
197
|
"claude-fable-5": {"input": 10.0, "output": 50.0},
|
|
190
198
|
"claude-mythos-5": {"input": 10.0, "output": 50.0},
|
|
191
199
|
"claude-opus-5": {"input": 5.0, "output": 25.0},
|
|
@@ -197,6 +205,9 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
197
205
|
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
|
198
206
|
"claude-sonnet-4-5": {"input": 3.0, "output": 15.0},
|
|
199
207
|
"claude-sonnet-4-5-20250929": {"input": 3.0, "output": 15.0},
|
|
208
|
+
# ponytail: Haiku 5.5 costs 5x for prompts over 100K tokens; a run's usage
|
|
209
|
+
# does not say how its prompts were sized, so this is the base tier.
|
|
210
|
+
"claude-haiku-5-5": {"input": 0.1, "output": 0.5, "cache_read": 0.01},
|
|
200
211
|
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
|
|
201
212
|
"claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
|
|
202
213
|
# OpenAI
|
|
@@ -218,7 +229,7 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
218
229
|
"gpt-5.4-nano-2026-03-17": {"input": 0.2, "output": 1.25},
|
|
219
230
|
"gpt-5.3-codex": {"input": 1.75, "output": 14.0},
|
|
220
231
|
}
|
|
221
|
-
TOKEN_PRICES_REVIEWED = "2026-10-
|
|
232
|
+
TOKEN_PRICES_REVIEWED = "2026-10-22"
|
|
222
233
|
#: How long a review stays fresh before the staleness test asks for another.
|
|
223
234
|
TOKEN_PRICES_MAX_AGE_DAYS = 90
|
|
224
235
|
TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
|
|
@@ -271,7 +282,7 @@ def estimate_cost(usage, model, prices=None):
|
|
|
271
282
|
"""
|
|
272
283
|
if not model or not isinstance(usage, dict):
|
|
273
284
|
return None
|
|
274
|
-
rate = (
|
|
285
|
+
rate = price_entry(model, prices)
|
|
275
286
|
if rate is None:
|
|
276
287
|
return None
|
|
277
288
|
per_million = {
|
package/src/session_helpers.py
CHANGED
|
@@ -98,6 +98,10 @@ def _process_is_running(pid):
|
|
|
98
98
|
return False
|
|
99
99
|
if pid <= 0:
|
|
100
100
|
return False
|
|
101
|
+
if sys.platform == "win32":
|
|
102
|
+
# os.kill(pid, 0) is not a probe on Windows: signal 0 is CTRL_C_EVENT,
|
|
103
|
+
# so it would send Ctrl+C to the process group instead of asking.
|
|
104
|
+
return _windows_process_is_running(pid)
|
|
101
105
|
try:
|
|
102
106
|
os.kill(pid, 0)
|
|
103
107
|
except ProcessLookupError:
|
|
@@ -108,6 +112,20 @@ def _process_is_running(pid):
|
|
|
108
112
|
return False
|
|
109
113
|
return True
|
|
110
114
|
|
|
115
|
+
|
|
116
|
+
def _windows_process_is_running(pid):
|
|
117
|
+
import ctypes
|
|
118
|
+
|
|
119
|
+
kernel32 = ctypes.windll.kernel32
|
|
120
|
+
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
121
|
+
if not handle:
|
|
122
|
+
return kernel32.GetLastError() == 5 # ERROR_ACCESS_DENIED: alive, someone else's
|
|
123
|
+
try:
|
|
124
|
+
code = ctypes.c_ulong()
|
|
125
|
+
return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
126
|
+
finally:
|
|
127
|
+
kernel32.CloseHandle(handle)
|
|
128
|
+
|
|
111
129
|
def _normalize_provider(provider):
|
|
112
130
|
value = provider or DEFAULT_PROVIDER
|
|
113
131
|
if value not in ALLOWED_PROVIDERS:
|
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,
|
|
@@ -145,6 +147,13 @@ def _seed_codex_auth_from_global(auth_home, env=None):
|
|
|
145
147
|
return True
|
|
146
148
|
|
|
147
149
|
|
|
150
|
+
_ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview", "scrub_env": "scrub env"}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _has_control_chars(text):
|
|
154
|
+
return any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
|
|
155
|
+
|
|
156
|
+
|
|
148
157
|
def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
149
158
|
normalized = {}
|
|
150
159
|
if not settings:
|
|
@@ -157,7 +166,7 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
157
166
|
if "reasoning_effort" in settings and settings["reasoning_effort"] is not None:
|
|
158
167
|
effort = str(settings["reasoning_effort"]).strip().lower()
|
|
159
168
|
if effort not in LAUNCH_REASONING_EFFORT_VALUES:
|
|
160
|
-
raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}")
|
|
169
|
+
raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}", code="invalid_reasoning_effort")
|
|
161
170
|
normalized["reasoning_effort"] = effort
|
|
162
171
|
if "permission" in settings and settings["permission"] is not None:
|
|
163
172
|
# Accepts the provider-native aliases (workspace-write, read-only,
|
|
@@ -172,77 +181,16 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
172
181
|
f"Unsupported permission: {settings['permission']} (allowed values: {allowed})"
|
|
173
182
|
)
|
|
174
183
|
normalized["permission"] = permission
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
text = str(value).strip().lower()
|
|
181
|
-
if text in ("on", "true", "1", "yes"):
|
|
182
|
-
normalized["fast"] = True
|
|
183
|
-
elif text in ("off", "false", "0", "no"):
|
|
184
|
-
normalized["fast"] = False
|
|
185
|
-
else:
|
|
186
|
-
raise CdxError(f"Unsupported fast value: {settings['fast']}")
|
|
187
|
-
if normalized["fast"] is True and mark_fast_service_tier:
|
|
188
|
-
normalized["fastMode"] = "service_tier"
|
|
189
|
-
else:
|
|
190
|
-
normalized.pop("fastMode", None)
|
|
191
|
-
if settings.get("fastMode") == "service_tier" and normalized["fast"] is True:
|
|
192
|
-
normalized["fastMode"] = "service_tier"
|
|
193
|
-
if "rtk" in settings and settings["rtk"] is not None:
|
|
194
|
-
value = settings["rtk"]
|
|
195
|
-
if isinstance(value, bool):
|
|
196
|
-
normalized["rtk"] = value
|
|
197
|
-
else:
|
|
198
|
-
text = str(value).strip().lower()
|
|
199
|
-
if text in ("on", "true", "1", "yes"):
|
|
200
|
-
normalized["rtk"] = True
|
|
201
|
-
elif text in ("off", "false", "0", "no"):
|
|
202
|
-
normalized["rtk"] = False
|
|
203
|
-
else:
|
|
204
|
-
raise CdxError(f"Unsupported rtk value: {settings['rtk']}")
|
|
205
|
-
if "logics" in settings and settings["logics"] is not None:
|
|
206
|
-
value = settings["logics"]
|
|
207
|
-
if isinstance(value, bool):
|
|
208
|
-
normalized["logics"] = value
|
|
209
|
-
else:
|
|
210
|
-
text = str(value).strip().lower()
|
|
211
|
-
if text in ("on", "true", "1", "yes"):
|
|
212
|
-
normalized["logics"] = True
|
|
213
|
-
elif text in ("off", "false", "0", "no"):
|
|
214
|
-
normalized["logics"] = False
|
|
215
|
-
else:
|
|
216
|
-
raise CdxError(f"Unsupported logics value: {settings['logics']}")
|
|
217
|
-
if "notify" in settings and settings["notify"] is not None:
|
|
218
|
-
value = settings["notify"]
|
|
219
|
-
if isinstance(value, bool):
|
|
220
|
-
normalized["notify"] = value
|
|
221
|
-
else:
|
|
222
|
-
text = str(value).strip().lower()
|
|
223
|
-
if text in ("on", "true", "1", "yes"):
|
|
224
|
-
normalized["notify"] = True
|
|
225
|
-
elif text in ("off", "false", "0", "no"):
|
|
226
|
-
normalized["notify"] = False
|
|
227
|
-
else:
|
|
228
|
-
raise CdxError(f"Unsupported notify value: {settings['notify']}")
|
|
229
|
-
if "notify_preview" in settings and settings["notify_preview"] is not None:
|
|
230
|
-
value = settings["notify_preview"]
|
|
231
|
-
if isinstance(value, bool):
|
|
232
|
-
normalized["notify_preview"] = value
|
|
233
|
-
else:
|
|
234
|
-
text = str(value).strip().lower()
|
|
235
|
-
if text in ("on", "true", "1", "yes"):
|
|
236
|
-
normalized["notify_preview"] = True
|
|
237
|
-
elif text in ("off", "false", "0", "no"):
|
|
238
|
-
normalized["notify_preview"] = False
|
|
239
|
-
else:
|
|
240
|
-
raise CdxError(f"Unsupported notify preview value: {settings['notify_preview']}")
|
|
184
|
+
for key, label in _ON_OFF_SETTINGS.items():
|
|
185
|
+
if key in settings and settings[key] is not None:
|
|
186
|
+
normalized[key] = _parse_on_off(settings[key], label)
|
|
187
|
+
if normalized.get("fast") is True and (mark_fast_service_tier or settings.get("fastMode") == "service_tier"):
|
|
188
|
+
normalized["fastMode"] = "service_tier"
|
|
241
189
|
if "model" in settings and settings["model"] is not None:
|
|
242
190
|
model = str(settings["model"]).strip()
|
|
243
191
|
if not model:
|
|
244
192
|
raise CdxError("Model cannot be empty.")
|
|
245
|
-
if len(model) > MAX_LAUNCH_MODEL_LENGTH or
|
|
193
|
+
if len(model) > MAX_LAUNCH_MODEL_LENGTH or _has_control_chars(model):
|
|
246
194
|
raise CdxError("Model contains unsupported characters.")
|
|
247
195
|
normalized["model"] = model
|
|
248
196
|
if "fallback_model" in settings and settings["fallback_model"] is not None:
|
|
@@ -255,14 +203,14 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
255
203
|
if any(not element for element in elements):
|
|
256
204
|
raise CdxError("Fallback model cannot contain an empty entry.")
|
|
257
205
|
for element in elements:
|
|
258
|
-
if len(element) > MAX_LAUNCH_MODEL_LENGTH or
|
|
206
|
+
if len(element) > MAX_LAUNCH_MODEL_LENGTH or _has_control_chars(element):
|
|
259
207
|
raise CdxError("Fallback model contains unsupported characters.")
|
|
260
208
|
normalized["fallback_model"] = ",".join(elements)
|
|
261
209
|
if "extra_args" in settings and settings["extra_args"] is not None:
|
|
262
210
|
extra = str(settings["extra_args"]).strip()
|
|
263
211
|
if not extra:
|
|
264
212
|
raise CdxError("Extra args cannot be empty.")
|
|
265
|
-
if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or
|
|
213
|
+
if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or _has_control_chars(extra):
|
|
266
214
|
raise CdxError("Extra args contain unsupported characters.")
|
|
267
215
|
# Parsed here, not at launch: a string that cannot be split into argv
|
|
268
216
|
# should be rejected when it is set, not when a run fails days later.
|
|
@@ -303,24 +251,18 @@ def _parse_status_timeout_seconds(value):
|
|
|
303
251
|
|
|
304
252
|
|
|
305
253
|
def _ensure_claude_attribution_disabled(auth_home):
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
settings = {}
|
|
314
|
-
except (OSError, json.JSONDecodeError):
|
|
315
|
-
settings = {}
|
|
316
|
-
if not isinstance(settings, dict):
|
|
317
|
-
settings = {}
|
|
318
|
-
settings["includeCoAuthoredBy"] = False
|
|
319
|
-
try:
|
|
320
|
-
atomic_write(settings_path, json.dumps(settings, indent=2, sort_keys=True) + "\n", mode=0o644)
|
|
321
|
-
except OSError:
|
|
254
|
+
# Same reader/writer as the notification hooks: an unparseable file is the
|
|
255
|
+
# user's and is left alone, a symlink stays a symlink, the mode is kept.
|
|
256
|
+
from .agent_notify import _read_json, _write_json
|
|
257
|
+
|
|
258
|
+
settings_path = os.path.join(auth_home, ".claude", "settings.json")
|
|
259
|
+
settings = _read_json(settings_path)
|
|
260
|
+
if settings is None:
|
|
322
261
|
return False
|
|
323
|
-
|
|
262
|
+
if settings.get("includeCoAuthoredBy") is False:
|
|
263
|
+
return True
|
|
264
|
+
settings["includeCoAuthoredBy"] = False
|
|
265
|
+
return _write_json(settings_path, settings)
|
|
324
266
|
|
|
325
267
|
|
|
326
268
|
def _normalize_session_label(label):
|
|
@@ -329,7 +271,7 @@ def _normalize_session_label(label):
|
|
|
329
271
|
raise CdxError("Session label is required")
|
|
330
272
|
if len(text) > MAX_SESSION_LABEL_LENGTH:
|
|
331
273
|
raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
|
|
332
|
-
if
|
|
274
|
+
if _has_control_chars(text):
|
|
333
275
|
raise CdxError("Session label cannot contain control characters")
|
|
334
276
|
return text
|
|
335
277
|
|
|
@@ -377,7 +319,7 @@ def unset_launch_settings(store, name, keys):
|
|
|
377
319
|
raise CdxError("At least one launch setting is required.")
|
|
378
320
|
allowed = {
|
|
379
321
|
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
380
|
-
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
322
|
+
"scrub_env", "model", "fallback_model", "budget", "extra_args", "priority",
|
|
381
323
|
}
|
|
382
324
|
unknown = [key for key in keys if key not in allowed]
|
|
383
325
|
if unknown:
|
|
@@ -911,7 +853,7 @@ def launch_session(store, name, resume=False):
|
|
|
911
853
|
if not session:
|
|
912
854
|
raise CdxError(f"Unknown session: {name}")
|
|
913
855
|
if session.get("enabled", True) is False:
|
|
914
|
-
raise CdxError(f"Session is disabled: {name}")
|
|
856
|
+
raise CdxError(f"Session is disabled: {name}", code="session_disabled")
|
|
915
857
|
# Reconcile the prior Claude conversation before a fresh launch replaces
|
|
916
858
|
# its id. This is a single exact transcript lookup, not a global stats scan.
|
|
917
859
|
backfill_interactive_usage(store, name=name, dry_run=False)
|
package/src/session_status.py
CHANGED
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
10
|
from datetime import datetime, timezone
|
|
11
11
|
|
|
12
|
+
from .claude_credentials import claude_auth_type, setup_token_days_left
|
|
12
13
|
from .claude_usage import _decode_jwt_claims
|
|
13
14
|
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX, get_cdx_home
|
|
14
15
|
from .errors import CdxError
|
|
@@ -269,6 +270,7 @@ def _status_row_from_session(store, s):
|
|
|
269
270
|
enabled = s.get("enabled", True) is not False
|
|
270
271
|
row_status = status if enabled else None
|
|
271
272
|
runtime = _session_runtime(store, s["name"])
|
|
273
|
+
auth_type = claude_auth_type(s["authHome"]) if s["provider"] == PROVIDER_CLAUDE and s.get("authHome") else None
|
|
272
274
|
return {
|
|
273
275
|
"session_name": s["name"],
|
|
274
276
|
"label": s.get("label"),
|
|
@@ -284,6 +286,8 @@ def _status_row_from_session(store, s):
|
|
|
284
286
|
"cwd": runtime.get("cwd") if runtime else None,
|
|
285
287
|
"status": "enabled" if enabled else "disabled",
|
|
286
288
|
"auth_status": (s.get("auth") or {}).get("status") or "unknown",
|
|
289
|
+
"auth_type": auth_type,
|
|
290
|
+
"auth_expires_in_days": setup_token_days_left(s["authHome"]) if auth_type == "setup-token" else None,
|
|
287
291
|
"auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
|
|
288
292
|
"remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
|
|
289
293
|
"remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
|
package/src/session_store.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import sys
|
|
4
|
-
import tempfile
|
|
5
4
|
from contextlib import contextmanager
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from .errors import CdxError
|
|
9
8
|
from .fs_utils import atomic_write
|
|
9
|
+
from .run_registry import REGISTRY_LOCK_TIMEOUT_SECONDS, _acquire_posix_lock, _acquire_windows_lock
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _ensure_dir(path):
|
|
@@ -34,23 +34,8 @@ def _read_json(file_path, fallback):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _write_json(file_path, value):
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(file_path)}.", suffix=".tmp", dir=directory)
|
|
40
|
-
try:
|
|
41
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
42
|
-
json.dump(value, f, indent=2)
|
|
43
|
-
f.write("\n")
|
|
44
|
-
f.flush()
|
|
45
|
-
os.fsync(f.fileno())
|
|
46
|
-
os.replace(temp_path, file_path)
|
|
47
|
-
_fsync_directory(directory)
|
|
48
|
-
except Exception:
|
|
49
|
-
try:
|
|
50
|
-
os.unlink(temp_path)
|
|
51
|
-
except FileNotFoundError:
|
|
52
|
-
pass
|
|
53
|
-
raise
|
|
37
|
+
_ensure_dir(os.path.dirname(file_path))
|
|
38
|
+
atomic_write(file_path, json.dumps(value, indent=2) + "\n")
|
|
54
39
|
|
|
55
40
|
|
|
56
41
|
def _fsync_directory(directory):
|
|
@@ -71,11 +56,8 @@ def _file_lock(lock_path):
|
|
|
71
56
|
with open(lock_path, "a", encoding="utf-8") as lock:
|
|
72
57
|
if sys.platform == "win32":
|
|
73
58
|
import msvcrt
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
|
|
77
|
-
except OSError as error:
|
|
78
|
-
raise CdxError(f"Failed to lock session store: {error}") from error
|
|
59
|
+
# Bounded wait, not LK_LOCK's ten one-second tries (see run_registry).
|
|
60
|
+
_acquire_windows_lock(lock, REGISTRY_LOCK_TIMEOUT_SECONDS, "session store")
|
|
79
61
|
try:
|
|
80
62
|
yield
|
|
81
63
|
finally:
|
|
@@ -89,10 +71,7 @@ def _file_lock(lock_path):
|
|
|
89
71
|
import fcntl
|
|
90
72
|
except ImportError as error:
|
|
91
73
|
raise CdxError("Session store locking requires fcntl on this platform") from error
|
|
92
|
-
|
|
93
|
-
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
94
|
-
except OSError as error:
|
|
95
|
-
raise CdxError(f"Failed to lock session store: {error}") from error
|
|
74
|
+
_acquire_posix_lock(lock, REGISTRY_LOCK_TIMEOUT_SECONDS, "session store")
|
|
96
75
|
try:
|
|
97
76
|
yield
|
|
98
77
|
finally:
|