nexo-brain 2.6.21 → 3.0.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
package/src/cli.py CHANGED
@@ -41,6 +41,11 @@ from pathlib import Path
41
41
 
42
42
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
43
43
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
44
+ TERMINAL_CLIENT_LABELS = {
45
+ "claude_code": "Claude Code",
46
+ "codex": "Codex",
47
+ }
48
+ TERMINAL_CLIENT_ORDER = ("claude_code", "codex")
44
49
 
45
50
 
46
51
  def _get_version() -> str:
@@ -844,8 +849,73 @@ def _dashboard(args):
844
849
  return _service_control("dashboard", args.action)
845
850
 
846
851
 
852
+ def _terminal_client_label(client: str) -> str:
853
+ return TERMINAL_CLIENT_LABELS.get(client, client.replace("_", " ").title())
854
+
855
+
856
+ def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> list[str]:
857
+ enabled = preferences.get("interactive_clients", {})
858
+ last_used = str(preferences.get("last_terminal_client", "")).strip()
859
+ preferred = str(preferences.get("default_terminal_client", "")).strip()
860
+ ordered: list[str] = []
861
+
862
+ for client in (last_used, preferred, *TERMINAL_CLIENT_ORDER):
863
+ if client in TERMINAL_CLIENT_ORDER and client not in ordered:
864
+ ordered.append(client)
865
+
866
+ return [
867
+ client
868
+ for client in ordered
869
+ if enabled.get(client, False) and detected.get(client, {}).get("installed", False)
870
+ ]
871
+
872
+
873
+ def _preferred_terminal_client_label(preferences: dict, clients: list[str]) -> str:
874
+ last_used = str(preferences.get("last_terminal_client", "")).strip()
875
+ if clients and clients[0] == last_used:
876
+ return "last choice"
877
+ return "default"
878
+
879
+
880
+ def _prompt_for_terminal_client(
881
+ clients: list[str],
882
+ normalize_client_key,
883
+ *,
884
+ preferred_label: str = "default",
885
+ ) -> str | None:
886
+ if not clients:
887
+ return None
888
+ if len(clients) == 1:
889
+ return clients[0]
890
+
891
+ while True:
892
+ print("Select terminal client for this chat:")
893
+ for index, client in enumerate(clients, start=1):
894
+ suffix = f" [{preferred_label}]" if index == 1 else ""
895
+ print(f" {index}. {_terminal_client_label(client)}{suffix}")
896
+
897
+ try:
898
+ response = input(f"Choose 1-{len(clients)} [1]: ").strip()
899
+ except EOFError:
900
+ return clients[0]
901
+
902
+ if not response:
903
+ return clients[0]
904
+ if response.isdigit():
905
+ choice = int(response)
906
+ if 1 <= choice <= len(clients):
907
+ return clients[choice - 1]
908
+
909
+ client_key = normalize_client_key(response)
910
+ if client_key in clients:
911
+ return client_key
912
+
913
+ print("Invalid choice. Try again.", file=sys.stderr)
914
+
915
+
847
916
  def _chat(args):
848
917
  target = args.path or "."
918
+ selected_client = getattr(args, "client", None)
849
919
 
850
920
  try:
851
921
  from auto_update import startup_preflight
@@ -867,20 +937,44 @@ def _chat(args):
867
937
  pass
868
938
 
869
939
  try:
940
+ from client_preferences import (
941
+ detect_installed_clients,
942
+ load_client_preferences,
943
+ normalize_client_key,
944
+ save_client_preferences,
945
+ )
870
946
  from agent_runner import TerminalClientUnavailableError, launch_interactive_client
871
947
  except ImportError:
872
948
  print("Agent runner module not found. Ensure NEXO is properly installed.", file=sys.stderr)
873
949
  return 1
874
950
 
951
+ if not selected_client:
952
+ try:
953
+ preferences = load_client_preferences()
954
+ detected = detect_installed_clients()
955
+ clients = _ordered_available_terminal_clients(preferences, detected)
956
+ selected_client = _prompt_for_terminal_client(
957
+ clients,
958
+ normalize_client_key,
959
+ preferred_label=_preferred_terminal_client_label(preferences, clients),
960
+ )
961
+ except Exception:
962
+ selected_client = None
963
+
875
964
  try:
876
965
  result = launch_interactive_client(
877
966
  target=target,
878
- client=getattr(args, "client", None),
967
+ client=selected_client,
879
968
  env=os.environ.copy(),
880
969
  )
881
970
  except TerminalClientUnavailableError as exc:
882
971
  print(str(exc), file=sys.stderr)
883
972
  return 1
973
+ if result.returncode == 0 and selected_client:
974
+ try:
975
+ save_client_preferences(last_terminal_client=normalize_client_key(selected_client))
976
+ except Exception:
977
+ pass
884
978
  return int(result.returncode)
885
979
 
886
980
 
@@ -962,6 +1056,23 @@ def _skills_apply(args):
962
1056
  return 0 if result.get("ok") else 1
963
1057
 
964
1058
 
1059
+ def _skills_test(args):
1060
+ from skills_runtime import test_skill
1061
+
1062
+ try:
1063
+ params = json.loads(args.params) if args.params else {}
1064
+ except json.JSONDecodeError as e:
1065
+ print(f"Invalid params JSON: {e}", file=sys.stderr)
1066
+ return 1
1067
+
1068
+ result = test_skill(args.id, params=params, mode=args.mode, context=args.context)
1069
+ if args.json:
1070
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1071
+ else:
1072
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1073
+ return 0 if result.get("ok") else 1
1074
+
1075
+
965
1076
  def _skills_sync(args):
966
1077
  from skills_runtime import sync_skills
967
1078
 
@@ -1009,12 +1120,68 @@ def _skills_evolution(args):
1009
1120
  return 0
1010
1121
 
1011
1122
 
1123
+ def _skills_promote(args):
1124
+ from skills_runtime import promote_skill
1125
+
1126
+ result = promote_skill(args.id, target_level=args.target_level, reason=args.reason)
1127
+ if args.json:
1128
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1129
+ else:
1130
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1131
+ return 0 if result.get("ok") else 1
1132
+
1133
+
1134
+ def _skills_retire(args):
1135
+ from skills_runtime import retire_skill
1136
+
1137
+ result = retire_skill(args.id, replacement_id=args.replacement_id, reason=args.reason)
1138
+ if args.json:
1139
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1140
+ else:
1141
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1142
+ return 0 if result.get("ok") else 1
1143
+
1144
+
1145
+ def _skills_compose(args):
1146
+ from skills_runtime import compose_skills
1147
+
1148
+ try:
1149
+ component_ids = json.loads(args.component_ids) if args.component_ids.strip().startswith("[") else [
1150
+ item.strip() for item in args.component_ids.split(",") if item.strip()
1151
+ ]
1152
+ tags = json.loads(args.tags) if args.tags.strip().startswith("[") else [
1153
+ item.strip() for item in args.tags.split(",") if item.strip()
1154
+ ]
1155
+ trigger_patterns = json.loads(args.trigger_patterns) if args.trigger_patterns.strip().startswith("[") else [
1156
+ item.strip() for item in args.trigger_patterns.split(",") if item.strip()
1157
+ ]
1158
+ except json.JSONDecodeError as e:
1159
+ print(f"Invalid JSON: {e}", file=sys.stderr)
1160
+ return 1
1161
+
1162
+ result = compose_skills(
1163
+ new_skill_id=args.new_id,
1164
+ name=args.name,
1165
+ component_ids=component_ids,
1166
+ description=args.description,
1167
+ level=args.level,
1168
+ mode=args.mode,
1169
+ tags=tags,
1170
+ trigger_patterns=trigger_patterns,
1171
+ )
1172
+ if args.json:
1173
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1174
+ else:
1175
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1176
+ return 0 if result.get("ok") else 1
1177
+
1178
+
1012
1179
  def _print_help():
1013
1180
  v = _get_version()
1014
1181
  print(f"""NEXO Runtime CLI v{v}
1015
1182
 
1016
1183
  Commands:
1017
- nexo chat [path] [--client claude_code|codex] Launch the selected terminal client
1184
+ nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
1018
1185
  nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
1019
1186
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
1020
1187
  Personal scripts
@@ -1036,12 +1203,12 @@ def main():
1036
1203
  sub = parser.add_subparsers(dest="command")
1037
1204
 
1038
1205
  # -- chat --
1039
- chat_parser = sub.add_parser("chat", help="Launch the selected terminal client")
1206
+ chat_parser = sub.add_parser("chat", help="Launch a NEXO terminal client")
1040
1207
  chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
1041
1208
  chat_parser.add_argument(
1042
1209
  "--client",
1043
1210
  choices=["claude_code", "codex"],
1044
- help="Override the configured default terminal client",
1211
+ help="Override the chat picker and launch a specific terminal client",
1045
1212
  )
1046
1213
 
1047
1214
  # -- scripts --
@@ -1154,6 +1321,13 @@ def main():
1154
1321
  skills_apply_p.add_argument("--context", default="", help="Usage context for feedback loop")
1155
1322
  skills_apply_p.add_argument("--json", action="store_true", help="JSON output")
1156
1323
 
1324
+ skills_test_p = skills_sub.add_parser("test", help="Dry-run test a skill")
1325
+ skills_test_p.add_argument("id", help="Skill ID")
1326
+ skills_test_p.add_argument("--params", default="{}", help="JSON parameters")
1327
+ skills_test_p.add_argument("--mode", default="auto", choices=["auto", "guide", "execute", "hybrid"])
1328
+ skills_test_p.add_argument("--context", default="", help="Testing context")
1329
+ skills_test_p.add_argument("--json", action="store_true", help="JSON output")
1330
+
1157
1331
  skills_sync_p = skills_sub.add_parser("sync", help="Sync filesystem skills")
1158
1332
  skills_sync_p.add_argument("--json", action="store_true", help="JSON output")
1159
1333
 
@@ -1170,6 +1344,29 @@ def main():
1170
1344
  skills_evolution_p = skills_sub.add_parser("evolution", help="Evolution candidates")
1171
1345
  skills_evolution_p.add_argument("--json", action="store_true", help="JSON output")
1172
1346
 
1347
+ skills_promote_p = skills_sub.add_parser("promote", help="Promote a skill lifecycle level")
1348
+ skills_promote_p.add_argument("id", help="Skill ID")
1349
+ skills_promote_p.add_argument("--target-level", default="published", choices=["draft", "published", "stable"])
1350
+ skills_promote_p.add_argument("--reason", default="", help="Why promote this skill")
1351
+ skills_promote_p.add_argument("--json", action="store_true", help="JSON output")
1352
+
1353
+ skills_retire_p = skills_sub.add_parser("retire", help="Archive a skill")
1354
+ skills_retire_p.add_argument("id", help="Skill ID")
1355
+ skills_retire_p.add_argument("--replacement-id", default="", help="Optional replacement skill ID")
1356
+ skills_retire_p.add_argument("--reason", default="", help="Why retire this skill")
1357
+ skills_retire_p.add_argument("--json", action="store_true", help="JSON output")
1358
+
1359
+ skills_compose_p = skills_sub.add_parser("compose", help="Compose multiple skills into one")
1360
+ skills_compose_p.add_argument("new_id", help="New skill ID")
1361
+ skills_compose_p.add_argument("name", help="New skill name")
1362
+ skills_compose_p.add_argument("--component-ids", required=True, help="JSON array or comma-separated skill IDs")
1363
+ skills_compose_p.add_argument("--description", default="", help="Composite skill description")
1364
+ skills_compose_p.add_argument("--level", default="draft", choices=["trace", "draft", "published", "stable"])
1365
+ skills_compose_p.add_argument("--mode", default="guide", choices=["guide", "hybrid"])
1366
+ skills_compose_p.add_argument("--tags", default="[]", help="JSON array or comma-separated tags")
1367
+ skills_compose_p.add_argument("--trigger-patterns", default="[]", help="JSON array or comma-separated trigger patterns")
1368
+ skills_compose_p.add_argument("--json", action="store_true", help="JSON output")
1369
+
1173
1370
  # -- dashboard --
1174
1371
  dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
1175
1372
  dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
@@ -1238,6 +1435,8 @@ def main():
1238
1435
  return _skills_get(args)
1239
1436
  elif args.skills_command == "apply":
1240
1437
  return _skills_apply(args)
1438
+ elif args.skills_command == "test":
1439
+ return _skills_test(args)
1241
1440
  elif args.skills_command == "sync":
1242
1441
  return _skills_sync(args)
1243
1442
  elif args.skills_command == "approve":
@@ -1246,6 +1445,12 @@ def main():
1246
1445
  return _skills_featured(args)
1247
1446
  elif args.skills_command == "evolution":
1248
1447
  return _skills_evolution(args)
1448
+ elif args.skills_command == "promote":
1449
+ return _skills_promote(args)
1450
+ elif args.skills_command == "retire":
1451
+ return _skills_retire(args)
1452
+ elif args.skills_command == "compose":
1453
+ return _skills_compose(args)
1249
1454
  else:
1250
1455
  skills_parser.print_help()
1251
1456
  return 0
@@ -30,6 +30,12 @@ AUTOMATION_BACKEND_KEYS = (
30
30
  CLIENT_CLAUDE_CODE,
31
31
  CLIENT_CODEX,
32
32
  )
33
+ AUTOMATION_TASK_PROFILE_KEYS = (
34
+ "default",
35
+ "fast",
36
+ "balanced",
37
+ "deep",
38
+ )
33
39
  INSTALL_PREFERENCE_KEYS = {
34
40
  "ask",
35
41
  "auto",
@@ -40,6 +46,10 @@ DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
40
46
  DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
41
47
  DEFAULT_CODEX_MODEL = "gpt-5.4"
42
48
  DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
49
+ DEFAULT_FAST_MODEL = "gpt-5.4-mini"
50
+ DEFAULT_FAST_REASONING_EFFORT = "medium"
51
+
52
+
43
53
  def _user_home() -> Path:
44
54
  return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
45
55
 
@@ -73,9 +83,11 @@ def default_client_preferences() -> dict:
73
83
  CLIENT_CLAUDE_DESKTOP: False,
74
84
  },
75
85
  "default_terminal_client": CLIENT_CLAUDE_CODE,
86
+ "last_terminal_client": "",
76
87
  "automation_enabled": True,
77
88
  "automation_backend": CLIENT_CLAUDE_CODE,
78
89
  "client_runtime_profiles": default_client_runtime_profiles(),
90
+ "automation_task_profiles": default_automation_task_profiles(),
79
91
  "client_install_preferences": {
80
92
  CLIENT_CLAUDE_CODE: "ask",
81
93
  CLIENT_CODEX: "ask",
@@ -189,6 +201,14 @@ def normalize_default_terminal_client(value, interactive_clients: dict[str, bool
189
201
  return CLIENT_CLAUDE_CODE
190
202
 
191
203
 
204
+ def normalize_last_terminal_client(value, interactive_clients: dict[str, bool] | None = None) -> str:
205
+ interactive_clients = normalize_interactive_clients(interactive_clients or {})
206
+ candidate = normalize_client_key(value)
207
+ if candidate in TERMINAL_CLIENT_KEYS and interactive_clients.get(candidate, False):
208
+ return candidate
209
+ return ""
210
+
211
+
192
212
  def normalize_automation_enabled(value) -> bool:
193
213
  return _coerce_bool(value, True)
194
214
 
@@ -228,6 +248,31 @@ def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
228
248
  }
229
249
 
230
250
 
251
+ def default_automation_task_profiles() -> dict[str, dict[str, str]]:
252
+ return {
253
+ "default": {
254
+ "backend": "",
255
+ "model": "",
256
+ "reasoning_effort": "",
257
+ },
258
+ "fast": {
259
+ "backend": CLIENT_CODEX,
260
+ "model": DEFAULT_FAST_MODEL,
261
+ "reasoning_effort": DEFAULT_FAST_REASONING_EFFORT,
262
+ },
263
+ "balanced": {
264
+ "backend": "",
265
+ "model": "",
266
+ "reasoning_effort": "",
267
+ },
268
+ "deep": {
269
+ "backend": CLIENT_CLAUDE_CODE,
270
+ "model": DEFAULT_CLAUDE_CODE_MODEL,
271
+ "reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
272
+ },
273
+ }
274
+
275
+
231
276
  def _normalize_runtime_model(value, *, default: str) -> str:
232
277
  candidate = str(value or "").strip()
233
278
  return candidate or default
@@ -267,6 +312,31 @@ def normalize_client_runtime_profiles(value) -> dict[str, dict[str, str]]:
267
312
  return normalized
268
313
 
269
314
 
315
+ def normalize_automation_task_profiles(value) -> dict[str, dict[str, str]]:
316
+ defaults = default_automation_task_profiles()
317
+ normalized = {key: dict(profile) for key, profile in defaults.items()}
318
+ if not isinstance(value, dict):
319
+ return normalized
320
+
321
+ for raw_profile, raw_value in value.items():
322
+ profile_key = str(raw_profile or "").strip().lower()
323
+ if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
324
+ continue
325
+ if not isinstance(raw_value, dict):
326
+ continue
327
+ backend = normalize_backend_key(raw_value.get("backend"))
328
+ if backend == BACKEND_NONE:
329
+ backend = ""
330
+ normalized[profile_key] = {
331
+ "backend": backend or defaults[profile_key]["backend"],
332
+ "model": str(raw_value.get("model") or defaults[profile_key]["model"]).strip(),
333
+ "reasoning_effort": str(
334
+ raw_value.get("reasoning_effort") or defaults[profile_key]["reasoning_effort"]
335
+ ).strip().lower(),
336
+ }
337
+ return normalized
338
+
339
+
270
340
  def normalize_client_preferences(
271
341
  schedule: dict | None = None,
272
342
  *,
@@ -282,6 +352,10 @@ def normalize_client_preferences(
282
352
  schedule.get("default_terminal_client"),
283
353
  interactive_clients=interactive_clients,
284
354
  )
355
+ last_terminal_client = normalize_last_terminal_client(
356
+ schedule.get("last_terminal_client"),
357
+ interactive_clients=interactive_clients,
358
+ )
285
359
  automation_backend = normalize_automation_backend(
286
360
  schedule.get("automation_backend"),
287
361
  automation_enabled=automation_enabled,
@@ -295,9 +369,13 @@ def normalize_client_preferences(
295
369
  return {
296
370
  "interactive_clients": interactive_clients,
297
371
  "default_terminal_client": default_terminal_client,
372
+ "last_terminal_client": last_terminal_client,
298
373
  "automation_enabled": automation_enabled,
299
374
  "automation_backend": automation_backend,
300
375
  "client_runtime_profiles": runtime_profiles,
376
+ "automation_task_profiles": normalize_automation_task_profiles(
377
+ schedule.get("automation_task_profiles")
378
+ ),
301
379
  "client_install_preferences": install_preferences,
302
380
  }
303
381
 
@@ -307,9 +385,11 @@ def apply_client_preferences(
307
385
  *,
308
386
  interactive_clients: dict | None = None,
309
387
  default_terminal_client: str | None = None,
388
+ last_terminal_client: str | None = None,
310
389
  automation_enabled=None,
311
390
  automation_backend: str | None = None,
312
391
  client_runtime_profiles: dict | None = None,
392
+ automation_task_profiles: dict | None = None,
313
393
  client_install_preferences: dict | None = None,
314
394
  ) -> dict:
315
395
  merged = dict(schedule or {})
@@ -324,6 +404,10 @@ def apply_client_preferences(
324
404
  default_terminal_client if default_terminal_client is not None else current["default_terminal_client"],
325
405
  interactive_clients=merged["interactive_clients"],
326
406
  )
407
+ merged["last_terminal_client"] = normalize_last_terminal_client(
408
+ last_terminal_client if last_terminal_client is not None else current.get("last_terminal_client", ""),
409
+ interactive_clients=merged["interactive_clients"],
410
+ )
327
411
  merged["automation_backend"] = normalize_automation_backend(
328
412
  automation_backend if automation_backend is not None else current["automation_backend"],
329
413
  automation_enabled=merged["automation_enabled"],
@@ -333,6 +417,11 @@ def apply_client_preferences(
333
417
  if client_runtime_profiles is not None
334
418
  else current["client_runtime_profiles"]
335
419
  )
420
+ merged["automation_task_profiles"] = normalize_automation_task_profiles(
421
+ automation_task_profiles
422
+ if automation_task_profiles is not None
423
+ else current["automation_task_profiles"]
424
+ )
336
425
  merged["client_install_preferences"] = normalize_client_install_preferences(
337
426
  client_install_preferences
338
427
  if client_install_preferences is not None
@@ -349,18 +438,22 @@ def save_client_preferences(
349
438
  *,
350
439
  interactive_clients: dict | None = None,
351
440
  default_terminal_client: str | None = None,
441
+ last_terminal_client: str | None = None,
352
442
  automation_enabled=None,
353
443
  automation_backend: str | None = None,
354
444
  client_runtime_profiles: dict | None = None,
445
+ automation_task_profiles: dict | None = None,
355
446
  client_install_preferences: dict | None = None,
356
447
  ) -> Path:
357
448
  schedule = apply_client_preferences(
358
449
  load_schedule_config(),
359
450
  interactive_clients=interactive_clients,
360
451
  default_terminal_client=default_terminal_client,
452
+ last_terminal_client=last_terminal_client,
361
453
  automation_enabled=automation_enabled,
362
454
  automation_backend=automation_backend,
363
455
  client_runtime_profiles=client_runtime_profiles,
456
+ automation_task_profiles=automation_task_profiles,
364
457
  client_install_preferences=client_install_preferences,
365
458
  )
366
459
  return save_schedule_config(schedule)
@@ -456,3 +549,25 @@ def resolve_client_runtime_profile(
456
549
  default=defaults[client_key]["reasoning_effort"],
457
550
  ),
458
551
  }
552
+
553
+
554
+ def resolve_automation_task_profile(
555
+ profile: str | None,
556
+ *,
557
+ preferences: dict | None = None,
558
+ ) -> dict[str, str]:
559
+ normalized = preferences or load_client_preferences()
560
+ defaults = default_automation_task_profiles()
561
+ profile_key = str(profile or "").strip().lower() or "default"
562
+ if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
563
+ profile_key = "default"
564
+ configured = normalize_automation_task_profiles(normalized.get("automation_task_profiles"))
565
+ selected = dict(configured.get(profile_key) or defaults[profile_key])
566
+ backend = selected.get("backend") or resolve_automation_backend(normalized)
567
+ runtime_profile = resolve_client_runtime_profile(backend, preferences=normalized)
568
+ return {
569
+ "name": profile_key,
570
+ "backend": backend,
571
+ "model": selected.get("model") or runtime_profile["model"],
572
+ "reasoning_effort": selected.get("reasoning_effort") or runtime_profile["reasoning_effort"],
573
+ }