nexo-brain 7.25.6 → 7.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.py CHANGED
@@ -2470,6 +2470,7 @@ def _clients_sync(args):
2470
2470
  enabled_clients=("claude_code", "claude_desktop", "codex"),
2471
2471
  preferences=load_client_preferences(),
2472
2472
  auto_install_missing_claude=bool(getattr(args, "auto_install_missing_claude", False)),
2473
+ auto_install_missing_codex=bool(getattr(args, "auto_install_missing_codex", False)),
2473
2474
  )
2474
2475
  if args.json:
2475
2476
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -2515,6 +2516,8 @@ def _preferences(args):
2515
2516
  from client_preferences import (
2516
2517
  BACKEND_NONE,
2517
2518
  AUTOMATION_BACKEND_KEYS,
2519
+ PROVIDER_KEYS,
2520
+ PROVIDER_NONE,
2518
2521
  load_client_preferences,
2519
2522
  normalize_default_terminal_client,
2520
2523
  save_client_preferences,
@@ -2536,7 +2539,21 @@ def _preferences(args):
2536
2539
  automation_enabled = False
2537
2540
 
2538
2541
  automation_backend = getattr(args, "automation_backend", None)
2539
- automation_changed = automation_enabled is not None or automation_backend is not None
2542
+ chat_provider = getattr(args, "chat_provider", None)
2543
+ automation_provider = getattr(args, "automation_provider", None)
2544
+ automation_changed = (
2545
+ automation_enabled is not None
2546
+ or automation_backend is not None
2547
+ or automation_provider is not None
2548
+ )
2549
+ provider_changed = chat_provider is not None or automation_provider is not None
2550
+
2551
+ if automation_backend and automation_provider:
2552
+ print(
2553
+ "[NEXO] Use either --automation-provider or --automation-backend, not both.",
2554
+ file=sys.stderr,
2555
+ )
2556
+ return 2
2540
2557
 
2541
2558
  if args.resonance:
2542
2559
  tier = args.resonance.lower()
@@ -2560,11 +2577,13 @@ def _preferences(args):
2560
2577
  interactive_clients=prefs.get("interactive_clients"),
2561
2578
  ) or "claude_code"
2562
2579
 
2563
- if tier or automation_changed:
2580
+ if tier or automation_changed or provider_changed:
2564
2581
  save_client_preferences(
2565
2582
  default_resonance=tier,
2566
2583
  automation_enabled=automation_enabled,
2567
2584
  automation_backend=automation_backend,
2585
+ selected_chat_provider=chat_provider,
2586
+ automation_provider=automation_provider,
2568
2587
  automation_user_override=True if automation_changed else None,
2569
2588
  )
2570
2589
  if tier:
@@ -2579,8 +2598,9 @@ def _preferences(args):
2579
2598
  ).strip().lower()
2580
2599
  current_resonance = calibration_value or schedule_value or DEFAULT_RESONANCE
2581
2600
 
2582
- if args.show or tier or automation_changed:
2601
+ if args.show or tier or automation_changed or provider_changed:
2583
2602
  is_explicit = bool(calibration_value or schedule_value)
2603
+ provider_runtime = prefs.get("provider_runtime") if isinstance(prefs.get("provider_runtime"), dict) else {}
2584
2604
  payload = {
2585
2605
  "default_resonance": current_resonance,
2586
2606
  "default_resonance_is_explicit": is_explicit,
@@ -2594,6 +2614,10 @@ def _preferences(args):
2594
2614
  "automation_user_override": bool(prefs.get("automation_user_override", False)),
2595
2615
  "available_automation_backends": list(AUTOMATION_BACKEND_KEYS),
2596
2616
  "default_terminal_client": str(prefs.get("default_terminal_client") or "claude_code"),
2617
+ "selected_chat_provider": str(provider_runtime.get("selected_chat_provider") or "anthropic"),
2618
+ "automation_provider": str(provider_runtime.get("automation_provider") or PROVIDER_NONE),
2619
+ "available_providers": list(PROVIDER_KEYS),
2620
+ "provider_runtime": provider_runtime,
2597
2621
  }
2598
2622
  if args.json:
2599
2623
  print(json.dumps(payload, indent=2, ensure_ascii=False))
@@ -2605,6 +2629,10 @@ def _preferences(args):
2605
2629
  + ("enabled" if payload["automation_enabled"] else "disabled")
2606
2630
  + f" · backend={payload['automation_backend']}"
2607
2631
  )
2632
+ print(
2633
+ "provider = "
2634
+ + f"chat:{payload['selected_chat_provider']} · automation:{payload['automation_provider']}"
2635
+ )
2608
2636
  if not is_explicit:
2609
2637
  print(f" (inherited from DEFAULT_RESONANCE; run "
2610
2638
  f"`nexo preferences --resonance alto` to set explicitly)")
@@ -2614,13 +2642,69 @@ def _preferences(args):
2614
2642
  print(
2615
2643
  "Usage: nexo preferences [--resonance TIER] "
2616
2644
  "[--automation-enabled|--automation-disabled] "
2617
- "[--automation-backend BACKEND] [--show] [--json]"
2645
+ "[--automation-backend BACKEND|--automation-provider PROVIDER] "
2646
+ "[--chat-provider PROVIDER] [--show] [--json]"
2618
2647
  )
2619
2648
  print(f" resonance tiers: {', '.join(TIERS)}")
2620
2649
  print(f" current default: {current_resonance}")
2621
2650
  return 0
2622
2651
 
2623
2652
 
2653
+ def _provider(args):
2654
+ """Provider runtime status/selection for Desktop and terminal parity."""
2655
+ from client_preferences import (
2656
+ PROVIDER_KEYS,
2657
+ detect_installed_clients,
2658
+ load_client_preferences,
2659
+ provider_to_client,
2660
+ save_client_preferences,
2661
+ )
2662
+
2663
+ command = getattr(args, "provider_command", "status") or "status"
2664
+ prefs = load_client_preferences()
2665
+ provider_runtime = prefs.get("provider_runtime") if isinstance(prefs.get("provider_runtime"), dict) else {}
2666
+ if command == "select":
2667
+ target = str(args.provider or "").strip().lower()
2668
+ if target not in PROVIDER_KEYS:
2669
+ print(f"[NEXO] Unknown provider '{target}'. Valid values: {', '.join(PROVIDER_KEYS)}.", file=sys.stderr)
2670
+ return 2
2671
+ chat_provider = None if getattr(args, "automation_only", False) else target
2672
+ automation_provider = None if getattr(args, "chat_only", False) else target
2673
+ save_client_preferences(
2674
+ selected_chat_provider=chat_provider,
2675
+ automation_provider=automation_provider,
2676
+ automation_user_override=True if automation_provider is not None else None,
2677
+ )
2678
+ prefs = load_client_preferences()
2679
+ provider_runtime = prefs.get("provider_runtime") if isinstance(prefs.get("provider_runtime"), dict) else {}
2680
+
2681
+ detected = detect_installed_clients()
2682
+ payload = {
2683
+ "selected_chat_provider": str(provider_runtime.get("selected_chat_provider") or "anthropic"),
2684
+ "automation_provider": str(provider_runtime.get("automation_provider") or "none"),
2685
+ "automation_backend": str(prefs.get("automation_backend") or "none"),
2686
+ "default_terminal_client": str(prefs.get("default_terminal_client") or "claude_code"),
2687
+ "providers": provider_runtime.get("providers") or {},
2688
+ "installed_clients": detected,
2689
+ }
2690
+ for provider in PROVIDER_KEYS:
2691
+ client = provider_to_client(provider)
2692
+ payload["providers"].setdefault(provider, {})
2693
+ payload["providers"][provider]["client"] = client
2694
+ payload["providers"][provider]["detected"] = detected.get(client, {})
2695
+
2696
+ if getattr(args, "json", False):
2697
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2698
+ else:
2699
+ print(f"chat_provider = {payload['selected_chat_provider']}")
2700
+ print(f"automation_provider = {payload['automation_provider']} · backend={payload['automation_backend']}")
2701
+ for provider in PROVIDER_KEYS:
2702
+ detected_provider = payload["providers"].get(provider, {}).get("detected", {})
2703
+ state = "installed" if detected_provider.get("installed") else "missing"
2704
+ print(f" {provider}: {state}")
2705
+ return 0
2706
+
2707
+
2624
2708
  def _contributor_status(args):
2625
2709
  public_contribution = _load_public_contribution_support()
2626
2710
  config = public_contribution["refresh_public_contribution_state"](
@@ -4058,6 +4142,11 @@ def main():
4058
4142
  action="store_true",
4059
4143
  help="Install Claude Code automatically when the selected runtime needs it and it is missing.",
4060
4144
  )
4145
+ clients_sync_p.add_argument(
4146
+ "--auto-install-missing-codex",
4147
+ action="store_true",
4148
+ help="Install Codex automatically when the selected runtime needs it and it is missing.",
4149
+ )
4061
4150
 
4062
4151
  # -- preferences --
4063
4152
  preferences_parser = sub.add_parser(
@@ -4093,8 +4182,30 @@ def main():
4093
4182
  choices=["claude_code", "codex", "none"],
4094
4183
  help="Backend used by background automations when they are enabled.",
4095
4184
  )
4185
+ preferences_parser.add_argument(
4186
+ "--chat-provider",
4187
+ choices=["anthropic", "openai"],
4188
+ help="Provider used for new chat sessions: Anthropic runs Claude Code, OpenAI runs Codex.",
4189
+ )
4190
+ preferences_parser.add_argument(
4191
+ "--automation-provider",
4192
+ choices=["anthropic", "openai", "none"],
4193
+ help="Provider used by background automations. 'none' pauses the runtime instead of falling back.",
4194
+ )
4096
4195
  preferences_parser.add_argument("--json", action="store_true", help="JSON output")
4097
4196
 
4197
+ # -- provider --
4198
+ provider_parser = sub.add_parser("provider", help="Provider runtime status and selection")
4199
+ provider_sub = provider_parser.add_subparsers(dest="provider_command")
4200
+ provider_status_p = provider_sub.add_parser("status", help="Show selected Anthropic/OpenAI runtime")
4201
+ provider_status_p.add_argument("--json", action="store_true", help="JSON output")
4202
+ provider_select_p = provider_sub.add_parser("select", help="Select Anthropic or OpenAI runtime")
4203
+ provider_select_p.add_argument("provider", choices=["anthropic", "openai"], help="Provider to select")
4204
+ provider_scope = provider_select_p.add_mutually_exclusive_group()
4205
+ provider_scope.add_argument("--chat-only", action="store_true", help="Only switch new chat sessions")
4206
+ provider_scope.add_argument("--automation-only", action="store_true", help="Only switch background automation")
4207
+ provider_select_p.add_argument("--json", action="store_true", help="JSON output")
4208
+
4098
4209
  # -- doctor --
4099
4210
  doctor_parser = sub.add_parser("doctor", help="Unified diagnostics")
4100
4211
  doctor_parser.add_argument("--tier", default="boot", choices=["boot", "runtime", "deep", "all"],
@@ -4585,6 +4696,8 @@ def main():
4585
4696
  return 0
4586
4697
  elif args.command == "preferences":
4587
4698
  return _preferences(args)
4699
+ elif args.command == "provider":
4700
+ return _provider(args)
4588
4701
  elif args.command == "doctor":
4589
4702
  return _doctor(args)
4590
4703
  elif args.command == "support-snapshot":
@@ -20,6 +20,22 @@ CLIENT_CLAUDE_CODE = "claude_code"
20
20
  CLIENT_CODEX = "codex"
21
21
  CLIENT_CLAUDE_DESKTOP = "claude_desktop"
22
22
  BACKEND_NONE = "none"
23
+ PROVIDER_ANTHROPIC = "anthropic"
24
+ PROVIDER_OPENAI = "openai"
25
+ PROVIDER_NONE = "none"
26
+
27
+ PROVIDER_TO_CLIENT = {
28
+ PROVIDER_ANTHROPIC: CLIENT_CLAUDE_CODE,
29
+ PROVIDER_OPENAI: CLIENT_CODEX,
30
+ }
31
+ CLIENT_TO_PROVIDER = {
32
+ CLIENT_CLAUDE_CODE: PROVIDER_ANTHROPIC,
33
+ CLIENT_CODEX: PROVIDER_OPENAI,
34
+ }
35
+ PROVIDER_KEYS = (
36
+ PROVIDER_ANTHROPIC,
37
+ PROVIDER_OPENAI,
38
+ )
23
39
 
24
40
  INTERACTIVE_CLIENT_KEYS = (
25
41
  CLIENT_CLAUDE_CODE,
@@ -67,6 +83,14 @@ AUTOMATION_TASK_PROFILE_TIERS = {
67
83
  }
68
84
 
69
85
 
86
+ def provider_to_client(provider: str | None) -> str:
87
+ return PROVIDER_TO_CLIENT.get(normalize_provider_key(provider), "")
88
+
89
+
90
+ def client_to_provider(client: str | None) -> str:
91
+ return CLIENT_TO_PROVIDER.get(normalize_client_key(client), "")
92
+
93
+
70
94
  def _user_home() -> Path:
71
95
  return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
72
96
 
@@ -158,6 +182,38 @@ def _managed_claude_binary(home: Path | None = None) -> str:
158
182
  return ""
159
183
 
160
184
 
185
+ def _managed_codex_binary(home: Path | None = None) -> str:
186
+ base = (home or _user_home()).expanduser()
187
+ managed_prefix = _managed_claude_prefix(base)
188
+ candidates = [managed_prefix / "bin" / "codex"]
189
+ for candidate in candidates:
190
+ try:
191
+ if candidate.exists() and _path_within(candidate, managed_prefix):
192
+ return str(candidate)
193
+ except Exception:
194
+ continue
195
+ return ""
196
+
197
+
198
+ def _managed_codex_vendor_present(home: Path | None = None) -> bool:
199
+ base = (home or _user_home()).expanduser()
200
+ managed_prefix = _managed_claude_prefix(base)
201
+ package_roots = (
202
+ managed_prefix / "lib" / "node_modules" / "@openai" / "codex",
203
+ managed_prefix / "node_modules" / "@openai" / "codex",
204
+ )
205
+ for package_root in package_roots:
206
+ vendor_root = package_root / "vendor"
207
+ if not vendor_root.exists():
208
+ continue
209
+ try:
210
+ if any(candidate.is_file() for candidate in vendor_root.rglob("bin/codex*")):
211
+ return True
212
+ except Exception:
213
+ continue
214
+ return False
215
+
216
+
161
217
  def _coerce_bool(value, default: bool) -> bool:
162
218
  if isinstance(value, bool):
163
219
  return value
@@ -182,6 +238,7 @@ def default_client_preferences() -> dict:
182
238
  "last_terminal_client": "",
183
239
  "automation_enabled": True,
184
240
  "automation_backend": CLIENT_CLAUDE_CODE,
241
+ "provider_runtime": default_provider_runtime(),
185
242
  # True iff the user has EXPLICITLY changed automation_backend from its
186
243
  # installer-chosen value. Installer/update flows (Fase E) only rewrite
187
244
  # automation_backend if this flag is False — respects user opt-out.
@@ -204,6 +261,59 @@ def default_client_preferences() -> dict:
204
261
  }
205
262
 
206
263
 
264
+ def default_provider_runtime() -> dict:
265
+ return {
266
+ "schema_version": 1,
267
+ "selected_chat_provider": PROVIDER_ANTHROPIC,
268
+ "automation_provider": PROVIDER_ANTHROPIC,
269
+ "automation_backend": CLIENT_CLAUDE_CODE,
270
+ "providers": {
271
+ PROVIDER_ANTHROPIC: {
272
+ "client": CLIENT_CLAUDE_CODE,
273
+ "runtime_account_status": {
274
+ "surface": "desktop_login",
275
+ "status": "unknown",
276
+ "plan": None,
277
+ "last_checked_at": None,
278
+ "detail": None,
279
+ },
280
+ "install_status": {
281
+ "installed": False,
282
+ "managed": True,
283
+ "binary_path": None,
284
+ "version": None,
285
+ },
286
+ },
287
+ PROVIDER_OPENAI: {
288
+ "client": CLIENT_CODEX,
289
+ "runtime_account_status": {
290
+ "surface": "desktop_login",
291
+ "status": "unknown",
292
+ "plan": None,
293
+ "last_checked_at": None,
294
+ "detail": None,
295
+ },
296
+ "install_status": {
297
+ "installed": False,
298
+ "managed": True,
299
+ "binary_path": None,
300
+ "version": None,
301
+ },
302
+ },
303
+ },
304
+ "fallback_policy": {
305
+ "chat": "ask",
306
+ "automation": "fail_closed",
307
+ },
308
+ "last_provider_change": {
309
+ "changed_at": None,
310
+ "from_provider": None,
311
+ "to_provider": None,
312
+ "source": None,
313
+ },
314
+ }
315
+
316
+
207
317
  def normalize_client_key(value: str | None) -> str:
208
318
  candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
209
319
  aliases = {
@@ -221,6 +331,27 @@ def normalize_client_key(value: str | None) -> str:
221
331
  return aliases.get(candidate, "")
222
332
 
223
333
 
334
+ def normalize_provider_key(value: str | None) -> str:
335
+ candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
336
+ aliases = {
337
+ "": "",
338
+ "none": PROVIDER_NONE,
339
+ "off": PROVIDER_NONE,
340
+ "disabled": PROVIDER_NONE,
341
+ "false": PROVIDER_NONE,
342
+ "0": PROVIDER_NONE,
343
+ "anthropic": PROVIDER_ANTHROPIC,
344
+ "anthropic_claude": PROVIDER_ANTHROPIC,
345
+ "anthropic_claude_code": PROVIDER_ANTHROPIC,
346
+ "claude": PROVIDER_ANTHROPIC,
347
+ "claude_code": PROVIDER_ANTHROPIC,
348
+ "openai": PROVIDER_OPENAI,
349
+ "openai_codex": PROVIDER_OPENAI,
350
+ "codex": PROVIDER_OPENAI,
351
+ }
352
+ return aliases.get(candidate, "")
353
+
354
+
224
355
  def normalize_backend_key(value: str | None) -> str:
225
356
  candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
226
357
  if candidate in {"", "none", "off", "disabled", "false", "0"}:
@@ -231,6 +362,75 @@ def normalize_backend_key(value: str | None) -> str:
231
362
  return ""
232
363
 
233
364
 
365
+ def _provider_from_runtime_payload(value, key: str, fallback_client: str) -> str:
366
+ if isinstance(value, dict):
367
+ provider = normalize_provider_key(value.get(key))
368
+ if provider in PROVIDER_KEYS:
369
+ return provider
370
+ return client_to_provider(fallback_client) or PROVIDER_ANTHROPIC
371
+
372
+
373
+ def normalize_provider_runtime(
374
+ value,
375
+ *,
376
+ default_terminal_client: str = CLIENT_CLAUDE_CODE,
377
+ automation_backend: str = CLIENT_CLAUDE_CODE,
378
+ automation_enabled: bool = True,
379
+ ) -> dict:
380
+ defaults = default_provider_runtime()
381
+ raw = value if isinstance(value, dict) else {}
382
+ selected_provider = _provider_from_runtime_payload(
383
+ raw,
384
+ "selected_chat_provider",
385
+ default_terminal_client,
386
+ )
387
+ raw_automation_provider = normalize_provider_key(raw.get("automation_provider")) if isinstance(raw, dict) else ""
388
+ automation_provider = (
389
+ PROVIDER_NONE
390
+ if raw_automation_provider == PROVIDER_NONE
391
+ else _provider_from_runtime_payload(
392
+ raw,
393
+ "automation_provider",
394
+ automation_backend if automation_enabled else BACKEND_NONE,
395
+ )
396
+ )
397
+ if not automation_enabled or automation_backend == BACKEND_NONE:
398
+ automation_provider = PROVIDER_NONE
399
+ elif automation_provider not in PROVIDER_KEYS:
400
+ automation_provider = client_to_provider(automation_backend) or PROVIDER_ANTHROPIC
401
+
402
+ providers = defaults["providers"]
403
+ raw_providers = raw.get("providers") if isinstance(raw.get("providers"), dict) else {}
404
+ normalized_providers = {}
405
+ for provider, expected_client in PROVIDER_TO_CLIENT.items():
406
+ raw_provider = raw_providers.get(provider) if isinstance(raw_providers.get(provider), dict) else {}
407
+ merged = dict(providers[provider])
408
+ merged.update({k: v for k, v in raw_provider.items() if k not in {"client"}})
409
+ merged["client"] = expected_client
410
+ normalized_providers[provider] = merged
411
+
412
+ fallback_policy = raw.get("fallback_policy") if isinstance(raw.get("fallback_policy"), dict) else {}
413
+ last_provider_change = raw.get("last_provider_change") if isinstance(raw.get("last_provider_change"), dict) else {}
414
+
415
+ return {
416
+ "schema_version": 1,
417
+ "selected_chat_provider": selected_provider,
418
+ "automation_provider": automation_provider,
419
+ "automation_backend": provider_to_client(automation_provider) if automation_provider in PROVIDER_KEYS else BACKEND_NONE,
420
+ "providers": normalized_providers,
421
+ "fallback_policy": {
422
+ "chat": str(fallback_policy.get("chat") or defaults["fallback_policy"]["chat"]),
423
+ "automation": "fail_closed",
424
+ },
425
+ "last_provider_change": {
426
+ "changed_at": last_provider_change.get("changed_at"),
427
+ "from_provider": normalize_provider_key(last_provider_change.get("from_provider")) or None,
428
+ "to_provider": normalize_provider_key(last_provider_change.get("to_provider")) or None,
429
+ "source": last_provider_change.get("source"),
430
+ },
431
+ }
432
+
433
+
234
434
  def normalize_interactive_clients(value) -> dict[str, bool]:
235
435
  if not isinstance(value, dict):
236
436
  return dict(default_client_preferences()["interactive_clients"])
@@ -339,7 +539,10 @@ def normalize_automation_user_override(value) -> bool:
339
539
  def normalize_automation_backend(value, *, automation_enabled: bool = True) -> str:
340
540
  if not automation_enabled:
341
541
  return BACKEND_NONE
542
+ raw = str(value or "").strip().lower()
342
543
  candidate = normalize_backend_key(value)
544
+ if candidate == BACKEND_NONE and raw in {"none", "off", "disabled", "false", "0"}:
545
+ return BACKEND_NONE
343
546
  if candidate in TERMINAL_CLIENT_KEYS:
344
547
  return candidate
345
548
  return CLIENT_CLAUDE_CODE
@@ -486,12 +689,27 @@ def normalize_client_preferences(
486
689
  runtime_profiles = normalize_client_runtime_profiles(
487
690
  schedule.get("client_runtime_profiles")
488
691
  )
692
+ provider_runtime = normalize_provider_runtime(
693
+ schedule.get("provider_runtime"),
694
+ default_terminal_client=default_terminal_client,
695
+ automation_backend=automation_backend,
696
+ automation_enabled=automation_enabled,
697
+ )
698
+ selected_client = provider_to_client(provider_runtime.get("selected_chat_provider"))
699
+ if selected_client in TERMINAL_CLIENT_KEYS:
700
+ interactive_clients[selected_client] = True
701
+ default_terminal_client = normalize_default_terminal_client(
702
+ selected_client,
703
+ interactive_clients=interactive_clients,
704
+ )
705
+ automation_backend = provider_runtime["automation_backend"]
489
706
  return {
490
707
  "interactive_clients": interactive_clients,
491
708
  "default_terminal_client": default_terminal_client,
492
709
  "last_terminal_client": last_terminal_client,
493
710
  "automation_enabled": automation_enabled,
494
711
  "automation_backend": automation_backend,
712
+ "provider_runtime": provider_runtime,
495
713
  "automation_user_override": automation_user_override,
496
714
  "client_runtime_profiles": runtime_profiles,
497
715
  "automation_task_profiles": normalize_automation_task_profiles(
@@ -530,6 +748,9 @@ def apply_client_preferences(
530
748
  last_terminal_client: str | None = None,
531
749
  automation_enabled=None,
532
750
  automation_backend: str | None = None,
751
+ provider_runtime: dict | None = None,
752
+ selected_chat_provider: str | None = None,
753
+ automation_provider: str | None = None,
533
754
  automation_user_override: bool | None = None,
534
755
  client_runtime_profiles: dict | None = None,
535
756
  automation_task_profiles: dict | None = None,
@@ -556,6 +777,45 @@ def apply_client_preferences(
556
777
  automation_backend if automation_backend is not None else current["automation_backend"],
557
778
  automation_enabled=merged["automation_enabled"],
558
779
  )
780
+ raw_provider_runtime = (
781
+ provider_runtime
782
+ if provider_runtime is not None
783
+ else current.get("provider_runtime")
784
+ )
785
+ raw_provider_runtime = dict(raw_provider_runtime or {})
786
+ if selected_chat_provider is not None:
787
+ raw_provider_runtime["selected_chat_provider"] = selected_chat_provider
788
+ derived_chat_client = provider_to_client(selected_chat_provider)
789
+ if derived_chat_client:
790
+ merged["interactive_clients"][derived_chat_client] = True
791
+ merged["default_terminal_client"] = derived_chat_client
792
+ elif default_terminal_client is not None:
793
+ raw_provider_runtime["selected_chat_provider"] = client_to_provider(
794
+ merged["default_terminal_client"]
795
+ )
796
+ if automation_provider is not None:
797
+ raw_provider_runtime["automation_provider"] = automation_provider
798
+ derived_backend = provider_to_client(automation_provider)
799
+ if derived_backend:
800
+ merged["interactive_clients"][derived_backend] = True
801
+ merged["automation_backend"] = (
802
+ derived_backend if derived_backend else BACKEND_NONE
803
+ )
804
+ elif automation_backend is not None or automation_enabled is not None:
805
+ raw_provider_runtime["automation_provider"] = client_to_provider(
806
+ merged["automation_backend"]
807
+ ) if merged["automation_backend"] != BACKEND_NONE else PROVIDER_NONE
808
+ merged["provider_runtime"] = normalize_provider_runtime(
809
+ raw_provider_runtime,
810
+ default_terminal_client=merged["default_terminal_client"],
811
+ automation_backend=merged["automation_backend"],
812
+ automation_enabled=merged["automation_enabled"],
813
+ )
814
+ merged["automation_backend"] = merged["provider_runtime"]["automation_backend"]
815
+ selected_client = provider_to_client(merged["provider_runtime"].get("selected_chat_provider"))
816
+ if selected_client in TERMINAL_CLIENT_KEYS:
817
+ merged["interactive_clients"][selected_client] = True
818
+ merged["default_terminal_client"] = selected_client
559
819
  merged["automation_user_override"] = normalize_automation_user_override(
560
820
  automation_user_override
561
821
  if automation_user_override is not None
@@ -597,6 +857,9 @@ def save_client_preferences(
597
857
  last_terminal_client: str | None = None,
598
858
  automation_enabled=None,
599
859
  automation_backend: str | None = None,
860
+ provider_runtime: dict | None = None,
861
+ selected_chat_provider: str | None = None,
862
+ automation_provider: str | None = None,
600
863
  automation_user_override: bool | None = None,
601
864
  client_runtime_profiles: dict | None = None,
602
865
  automation_task_profiles: dict | None = None,
@@ -611,6 +874,9 @@ def save_client_preferences(
611
874
  last_terminal_client=last_terminal_client,
612
875
  automation_enabled=automation_enabled,
613
876
  automation_backend=automation_backend,
877
+ provider_runtime=provider_runtime,
878
+ selected_chat_provider=selected_chat_provider,
879
+ automation_provider=automation_provider,
614
880
  automation_user_override=automation_user_override,
615
881
  client_runtime_profiles=client_runtime_profiles,
616
882
  automation_task_profiles=automation_task_profiles,
@@ -666,7 +932,11 @@ def detect_installed_clients(user_home: str | os.PathLike[str] | None = None) ->
666
932
  claude_bin = _managed_claude_binary(home)
667
933
  else:
668
934
  claude_bin = os.environ.get("CLAUDE_BIN", "").strip() or _which_with_nvm("claude", home)
669
- codex_bin = os.environ.get("CODEX_BIN", "").strip() or _which_with_nvm("codex", home)
935
+ if _desktop_product_requested(home):
936
+ managed_codex_bin = _managed_codex_binary(home)
937
+ codex_bin = managed_codex_bin if managed_codex_bin and _managed_codex_vendor_present(home) else ""
938
+ else:
939
+ codex_bin = os.environ.get("CODEX_BIN", "").strip() or _which_with_nvm("codex", home)
670
940
 
671
941
  if sys.platform == "darwin":
672
942
  desktop_app = next(
@@ -714,6 +984,28 @@ def resolve_terminal_client(requested: str | None = None, *, preferences: dict |
714
984
  )
715
985
 
716
986
 
987
+ def resolve_selected_chat_provider(preferences: dict | None = None) -> str:
988
+ normalized = preferences or load_client_preferences()
989
+ provider_runtime = normalize_provider_runtime(
990
+ normalized.get("provider_runtime"),
991
+ default_terminal_client=normalized.get("default_terminal_client", CLIENT_CLAUDE_CODE),
992
+ automation_backend=normalized.get("automation_backend", CLIENT_CLAUDE_CODE),
993
+ automation_enabled=normalized.get("automation_enabled", True),
994
+ )
995
+ return provider_runtime["selected_chat_provider"]
996
+
997
+
998
+ def resolve_automation_provider(preferences: dict | None = None) -> str:
999
+ normalized = preferences or load_client_preferences()
1000
+ provider_runtime = normalize_provider_runtime(
1001
+ normalized.get("provider_runtime"),
1002
+ default_terminal_client=normalized.get("default_terminal_client", CLIENT_CLAUDE_CODE),
1003
+ automation_backend=normalized.get("automation_backend", CLIENT_CLAUDE_CODE),
1004
+ automation_enabled=normalized.get("automation_enabled", True),
1005
+ )
1006
+ return provider_runtime["automation_provider"]
1007
+
1008
+
717
1009
  def resolve_user_model(preferences: dict | None = None) -> str:
718
1010
  """Return the single model the user has configured.
719
1011