nexo-brain 7.25.6 → 7.27.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -1
  3. package/bin/nexo-brain.js +235 -31
  4. package/codex/openai-codex-0.133.0.tgz +0 -0
  5. package/package.json +7 -1
  6. package/src/agent_runner.py +99 -32
  7. package/src/call_model_raw.py +1 -1
  8. package/src/cli.py +117 -4
  9. package/src/client_preferences.py +296 -1
  10. package/src/client_sync.py +343 -6
  11. package/src/db/_schema.py +23 -0
  12. package/src/db/_sessions.py +75 -24
  13. package/src/enforcement_classifier.py +1 -1
  14. package/src/local_context/api.py +58 -45
  15. package/src/model_defaults.json +4 -4
  16. package/src/model_defaults.py +4 -4
  17. package/src/provider_runtime.py +39 -0
  18. package/src/resonance_tiers.json +5 -5
  19. package/src/scripts/deep-sleep/extract.py +2 -0
  20. package/src/scripts/deep-sleep/synthesize.py +1 -0
  21. package/src/scripts/nexo-cron-wrapper.sh +108 -25
  22. package/src/scripts/nexo-morning-agent.py +1 -1
  23. package/src/server.py +3 -1
  24. package/src/tools_automation_sessions.py +2 -1
  25. package/src/tools_sessions.py +13 -8
  26. package/templates/launchagents/README.md +2 -2
  27. package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
  28. package/templates/launchagents/com.nexo.catchup.plist +3 -3
  29. package/templates/launchagents/com.nexo.cognitive-decay.plist +3 -3
  30. package/templates/launchagents/com.nexo.deep-sleep.plist +3 -3
  31. package/templates/launchagents/com.nexo.evolution.plist +3 -3
  32. package/templates/launchagents/com.nexo.followup-hygiene.plist +3 -3
  33. package/templates/launchagents/com.nexo.immune.plist +1 -1
  34. package/templates/launchagents/com.nexo.postmortem.plist +3 -3
  35. package/templates/launchagents/com.nexo.self-audit.plist +3 -3
  36. package/templates/launchagents/com.nexo.synthesis.plist +1 -1
  37. package/templates/launchagents/com.nexo.watchdog.plist +3 -3
@@ -24,8 +24,12 @@ from client_preferences import (
24
24
  BACKEND_NONE,
25
25
  CLIENT_CLAUDE_CODE,
26
26
  CLIENT_CODEX,
27
+ client_to_provider,
27
28
  TERMINAL_CLIENT_KEYS,
28
29
  load_client_preferences,
30
+ _desktop_product_requested,
31
+ _managed_codex_binary,
32
+ _managed_codex_vendor_present,
29
33
  normalize_client_key,
30
34
  resolve_automation_backend,
31
35
  resolve_client_runtime_profile,
@@ -40,6 +44,7 @@ CLAUDE_LEGACY_MODEL_HINTS = {"opus", "sonnet"}
40
44
  MODEL_PRICING_USD_PER_1M = {
41
45
  # Pricing snapshot used only when the backend does not return explicit cost.
42
46
  # Codex model names map to the current GPT-5 family pricing.
47
+ "gpt-5.5": {"input": 1.25, "cached_input": 0.125, "output": 10.0},
43
48
  "gpt-5.4": {"input": 1.25, "cached_input": 0.125, "output": 10.0},
44
49
  "gpt-5.4-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.0},
45
50
  }
@@ -54,11 +59,16 @@ class TerminalClientUnavailableError(AgentRunnerError):
54
59
  class AutomationBackendUnavailableError(AgentRunnerError):
55
60
  """Raised when the configured automation backend is unavailable."""
56
61
 
62
+
63
+ def _automation_provider_for_backend(backend: str) -> str:
64
+ return client_to_provider(backend) or ""
65
+
57
66
  def _canonical_pricing_model(model: str) -> str:
58
67
  lowered = str(model or "").strip().lower()
59
68
  lowered = lowered.split("[", 1)[0]
60
69
  aliases = {
61
- "gpt-5": "gpt-5.4",
70
+ "gpt-5": "gpt-5.5",
71
+ "gpt-5.5": "gpt-5.5",
62
72
  "gpt-5.4": "gpt-5.4",
63
73
  "gpt-5-mini": "gpt-5.4-mini",
64
74
  "gpt-5.4-mini": "gpt-5.4-mini",
@@ -199,6 +209,7 @@ def _record_automation_start(
199
209
  *,
200
210
  caller: str,
201
211
  backend: str,
212
+ provider: str = "",
202
213
  session_type: str,
203
214
  task_profile: str,
204
215
  model: str,
@@ -225,14 +236,15 @@ def _record_automation_start(
225
236
  cur = conn.execute(
226
237
  """
227
238
  INSERT INTO automation_runs (
228
- caller, backend, session_type, task_profile, model,
239
+ caller, backend, provider, session_type, task_profile, model,
229
240
  reasoning_effort, resonance_tier, cwd, output_format,
230
241
  prompt_chars, status, started_at, pid
231
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', datetime('now'), ?)
242
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', datetime('now'), ?)
232
243
  """,
233
244
  (
234
245
  caller or "",
235
246
  backend,
247
+ provider or _automation_provider_for_backend(backend),
236
248
  session_type or "headless",
237
249
  task_profile or "default",
238
250
  model or "",
@@ -339,6 +351,7 @@ def _record_automation_end(
339
351
  def _record_automation_run(
340
352
  *,
341
353
  backend: str,
354
+ provider: str = "",
342
355
  task_profile: str,
343
356
  model: str,
344
357
  reasoning_effort: str,
@@ -359,6 +372,7 @@ def _record_automation_run(
359
372
  row_id, err = _record_automation_start(
360
373
  caller=caller,
361
374
  backend=backend,
375
+ provider=provider or _automation_provider_for_backend(backend),
362
376
  session_type=session_type,
363
377
  task_profile=task_profile,
364
378
  model=model,
@@ -378,14 +392,57 @@ def _record_automation_run(
378
392
  )
379
393
 
380
394
 
395
+ def _record_backend_unavailable(
396
+ *,
397
+ backend: str,
398
+ caller: str,
399
+ cwd: Path,
400
+ prompt: str,
401
+ ) -> None:
402
+ profile = resolve_client_runtime_profile(backend)
403
+ _record_automation_run(
404
+ backend=backend,
405
+ provider=_automation_provider_for_backend(backend),
406
+ task_profile="default",
407
+ model=profile.get("model", ""),
408
+ reasoning_effort=profile.get("reasoning_effort", ""),
409
+ cwd=cwd,
410
+ output_format="text",
411
+ prompt=prompt,
412
+ returncode=2,
413
+ duration_ms=0,
414
+ telemetry={
415
+ "telemetry_source": "backend_unavailable",
416
+ "cost_source": "missing",
417
+ "usage": {},
418
+ "warnings": ["backend_unavailable", "fallback_blocked"],
419
+ "raw": {
420
+ "event": "backend_unavailable",
421
+ "backend": backend,
422
+ "provider": _automation_provider_for_backend(backend),
423
+ "fallback_policy": "fail_closed",
424
+ },
425
+ },
426
+ caller=caller,
427
+ session_type="headless",
428
+ )
429
+
430
+
381
431
  def _resolve_claude_cli() -> str:
382
432
  return _shared_resolve_claude_cli()
383
433
 
384
434
 
385
435
  def _resolve_codex_cli() -> str:
436
+ home = Path.home()
437
+ if _desktop_product_requested(home):
438
+ managed_path = _managed_codex_binary(home)
439
+ return managed_path if managed_path and _managed_codex_vendor_present(home) else ""
386
440
  env_path = os.environ.get("CODEX_BIN", "").strip()
387
441
  if env_path and Path(env_path).exists():
388
442
  return env_path
443
+ managed_path = _managed_codex_binary(home)
444
+ if managed_path and _managed_codex_vendor_present(home):
445
+ return managed_path
389
446
  return shutil.which("codex") or ""
390
447
 
391
448
 
@@ -790,6 +847,7 @@ def run_automation_interactive(
790
847
  row_id, _record_err = _record_automation_start(
791
848
  caller=caller,
792
849
  backend=resolved_client,
850
+ provider=_automation_provider_for_backend(resolved_client),
793
851
  session_type=session_type,
794
852
  task_profile="",
795
853
  model="",
@@ -905,15 +963,6 @@ def _backend_is_available(backend: str) -> bool:
905
963
 
906
964
 
907
965
  def _resolve_available_backend(selected_backend: str, *, preferences: dict | None = None) -> str:
908
- if _backend_is_available(selected_backend):
909
- return selected_backend
910
- prefs = preferences or load_client_preferences()
911
- preferred = resolve_automation_backend(preferences=prefs)
912
- for candidate in (preferred, CLIENT_CLAUDE_CODE, CLIENT_CODEX):
913
- if candidate == selected_backend or candidate == BACKEND_NONE:
914
- continue
915
- if _backend_is_available(candidate):
916
- return candidate
917
966
  return selected_backend
918
967
 
919
968
 
@@ -1011,7 +1060,7 @@ _ANTHROPIC_API_KEY_SEARCH_PATHS = (
1011
1060
 
1012
1061
 
1013
1062
  def _resolve_anthropic_api_key() -> str:
1014
- """Locate an Anthropic API key for bare-mode invocations.
1063
+ """Locate a legacy Anthropic API key for opt-in bare-mode invocations.
1015
1064
 
1016
1065
  ``claude --bare`` skips macOS Keychain auth entirely, so the child
1017
1066
  must find the API key in ``ANTHROPIC_API_KEY`` or via ``apiKeyHelper``.
@@ -1039,15 +1088,16 @@ def _resolve_anthropic_api_key() -> str:
1039
1088
  return ""
1040
1089
 
1041
1090
 
1042
- # Callers for which bare_mode=True is safe. The child's only allowed_tools
1043
- # must be file/grep/shell (no ``mcp__nexo__*``), otherwise --bare's opt-out
1044
- # of plugin sync / MCP bootstrap breaks the run. Extract/synthesize fit
1045
- # this profile: they read transcripts + shared-context and emit JSON, no
1046
- # NEXO tool calls.
1047
- BARE_MODE_SAFE_CALLERS: frozenset[str] = frozenset({
1048
- "deep-sleep/extract",
1049
- "deep-sleep/synthesize",
1050
- })
1091
+ def _allow_anthropic_api_key_bare_mode() -> bool:
1092
+ value = os.environ.get("NEXO_ALLOW_ANTHROPIC_API_BARE", "").strip().lower()
1093
+ return value in {"1", "true", "yes", "on"}
1094
+
1095
+
1096
+ # v7.26 provider-runtime contract: Desktop-managed automation must use the
1097
+ # selected account runtime (Claude Code or Codex), not an Anthropic API key
1098
+ # side path. Bare mode remains a hidden legacy opt-in for controlled local
1099
+ # experiments only via NEXO_ALLOW_ANTHROPIC_API_BARE=1.
1100
+ BARE_MODE_SAFE_CALLERS: frozenset[str] = frozenset()
1051
1101
 
1052
1102
  # Execution contracts keep background agents disciplined without polluting
1053
1103
  # machine-only child calls that must return strict JSON.
@@ -1153,6 +1203,17 @@ def run_automation_prompt(
1153
1203
  if selected_backend == BACKEND_NONE:
1154
1204
  raise AutomationBackendUnavailableError("Automation backend is disabled in config.")
1155
1205
  selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
1206
+ if not _backend_is_available(selected_backend):
1207
+ cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
1208
+ _record_backend_unavailable(
1209
+ backend=selected_backend,
1210
+ caller=caller,
1211
+ cwd=cwd_path,
1212
+ prompt=prompt,
1213
+ )
1214
+ raise AutomationBackendUnavailableError(
1215
+ f"{selected_backend} automation backend selected but launcher is not installed; fallback blocked."
1216
+ )
1156
1217
 
1157
1218
  # Resonance map decides (model, effort) for every call. ``caller`` is
1158
1219
  # MANDATORY — every script that invokes the automation backend must be
@@ -1256,13 +1317,13 @@ def run_automation_prompt(
1256
1317
  # completes in seconds.
1257
1318
  #
1258
1319
  # Selection rules:
1259
- # - bare_mode=True explicit → trust the caller.
1320
+ # - bare_mode=True explicit → allow only when the legacy API-key
1321
+ # escape hatch is enabled.
1260
1322
  # - bare_mode=None (default) + caller in BARE_MODE_SAFE_CALLERS
1261
- # → auto-enable.
1323
+ # → auto-enable. v7.26 keeps this set empty for provider parity.
1262
1324
  # - bare_mode=False → never.
1263
- # - --bare disables keychain auth, so we must provide an
1264
- # ANTHROPIC_API_KEY. If one cannot be located, fall back to
1265
- # normal mode with a warning on stderr rather than failing.
1325
+ # - --bare disables keychain auth, so the legacy path requires
1326
+ # ANTHROPIC_API_KEY. If disabled or missing, use normal account auth.
1266
1327
  resolved_bare = False
1267
1328
  if bare_mode is True:
1268
1329
  resolved_bare = True
@@ -1271,12 +1332,14 @@ def run_automation_prompt(
1271
1332
 
1272
1333
  bare_api_key = ""
1273
1334
  if resolved_bare:
1274
- bare_api_key = _resolve_anthropic_api_key()
1275
- if not bare_api_key:
1276
- # Silent fallback: we would rather take the slower path
1277
- # than force the caller to fail-closed on an env quirk.
1335
+ if not _allow_anthropic_api_key_bare_mode():
1278
1336
  resolved_bare = False
1279
-
1337
+ else:
1338
+ bare_api_key = _resolve_anthropic_api_key()
1339
+ if not bare_api_key:
1340
+ # Silent fallback: we would rather take the slower path
1341
+ # than force the caller to fail-closed on an env quirk.
1342
+ resolved_bare = False
1280
1343
  # Headless claude -p does NOT reliably honour permissions.allow from
1281
1344
  # settings.json for MCP tool calls — it can stall waiting for an
1282
1345
  # approval that will never come in non-interactive mode. All NEXO
@@ -1293,6 +1356,8 @@ def run_automation_prompt(
1293
1356
  run_env = dict(run_env)
1294
1357
  run_env["ANTHROPIC_API_KEY"] = bare_api_key
1295
1358
  else:
1359
+ run_env = dict(run_env)
1360
+ run_env.pop("ANTHROPIC_API_KEY", None)
1296
1361
  cmd.append("--dangerously-skip-permissions")
1297
1362
  if resolved_model:
1298
1363
  cmd.extend(["--model", resolved_model])
@@ -1342,6 +1407,7 @@ def run_automation_prompt(
1342
1407
  telemetry["automation_contract"] = automation_contract
1343
1408
  recorded, record_error = _record_automation_run(
1344
1409
  backend=selected_backend,
1410
+ provider=_automation_provider_for_backend(selected_backend),
1345
1411
  task_profile=task_profile,
1346
1412
  model=resolved_model,
1347
1413
  reasoning_effort=resolved_effort,
@@ -1420,6 +1486,7 @@ def run_automation_prompt(
1420
1486
  telemetry["automation_contract"] = automation_contract
1421
1487
  recorded, record_error = _record_automation_run(
1422
1488
  backend=selected_backend,
1489
+ provider=_automation_provider_for_backend(selected_backend),
1423
1490
  task_profile=task_profile,
1424
1491
  model=resolved_model,
1425
1492
  reasoning_effort=resolved_effort,
@@ -260,7 +260,7 @@ def call_model_raw(
260
260
  Parameters follow the Fase 2 plan doc 1 spec:
261
261
 
262
262
  prompt — the user-role text (English or the model's default).
263
- tier — resonance tier; default "muy_bajo" → Haiku / gpt-5.4-mini.
263
+ tier — resonance tier; default "muy_bajo" → Haiku / gpt-5.5 low.
264
264
  caller — resonance caller label. Must be registered in
265
265
  resonance_map.SYSTEM_OWNED_CALLERS. Default
266
266
  "enforcer_classifier".
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":