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.
@@ -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
- try:
254
- with open(os.path.join(auth_home, ".claude", ".credentials.json"), encoding="utf-8") as handle:
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 "")
@@ -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
- if not pid:
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 = (prices if prices is not None else token_prices()[0]).get(model or "")
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 = (prices if prices is not None else token_prices()[0]).get(model or "")
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-04"
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 = (prices if prices is not None else token_prices()[0]).get(model)
285
+ rate = price_entry(model, prices)
275
286
  if rate is None:
276
287
  return None
277
288
  per_million = {
@@ -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:
@@ -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 atomic_write, remove_tree
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
- if "fast" in settings and settings["fast"] is not None:
176
- value = settings["fast"]
177
- if isinstance(value, bool):
178
- normalized["fast"] = value
179
- else:
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 any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
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 any(ord(ch) < 32 or ord(ch) == 127 for ch in element):
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 any(ord(ch) < 32 or ord(ch) == 127 for ch in extra):
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
- settings_dir = os.path.join(auth_home, ".claude")
307
- settings_path = os.path.join(settings_dir, "settings.json")
308
- try:
309
- os.makedirs(settings_dir, exist_ok=True)
310
- with open(settings_path, encoding="utf-8") as handle:
311
- settings = json.load(handle)
312
- except FileNotFoundError:
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
- return True
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 any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
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)
@@ -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,
@@ -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
- directory = os.path.dirname(file_path)
38
- _ensure_dir(directory)
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
- try:
75
- lock.seek(0)
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
- try:
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: