nexo-brain 2.6.11 → 2.6.13

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.
@@ -0,0 +1,322 @@
1
+ from __future__ import annotations
2
+
3
+ """Terminal client launchers and headless automation backend runner."""
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from client_preferences import (
12
+ BACKEND_NONE,
13
+ CLIENT_CLAUDE_CODE,
14
+ CLIENT_CODEX,
15
+ TERMINAL_CLIENT_KEYS,
16
+ load_client_preferences,
17
+ resolve_automation_backend,
18
+ resolve_client_runtime_profile,
19
+ resolve_terminal_client,
20
+ )
21
+
22
+
23
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
24
+ CLAUDE_LEGACY_MODEL_HINTS = {"opus", "sonnet"}
25
+
26
+
27
+ class AgentRunnerError(RuntimeError):
28
+ """Base exception for runner failures."""
29
+
30
+
31
+ class TerminalClientUnavailableError(AgentRunnerError):
32
+ """Raised when the requested interactive client cannot be launched."""
33
+
34
+
35
+ class AutomationBackendUnavailableError(AgentRunnerError):
36
+ """Raised when the configured automation backend is unavailable."""
37
+
38
+
39
+ def _resolve_claude_cli() -> str:
40
+ saved = NEXO_HOME / "config" / "claude-cli-path"
41
+ if saved.exists():
42
+ candidate = saved.read_text().strip()
43
+ if candidate and Path(candidate).exists():
44
+ return candidate
45
+ env_path = os.environ.get("CLAUDE_BIN", "").strip()
46
+ if env_path and Path(env_path).exists():
47
+ return env_path
48
+ discovered = shutil.which("claude")
49
+ if discovered:
50
+ return discovered
51
+ for candidate in (
52
+ Path.home() / ".local" / "bin" / "claude",
53
+ Path.home() / ".npm-global" / "bin" / "claude",
54
+ Path("/usr/local/bin/claude"),
55
+ ):
56
+ if candidate.exists():
57
+ return str(candidate)
58
+ return ""
59
+
60
+
61
+ def _resolve_codex_cli() -> str:
62
+ env_path = os.environ.get("CODEX_BIN", "").strip()
63
+ if env_path and Path(env_path).exists():
64
+ return env_path
65
+ return shutil.which("codex") or ""
66
+
67
+
68
+ def _headless_env(env: dict | None = None) -> dict:
69
+ merged = os.environ.copy()
70
+ if env:
71
+ merged.update(env)
72
+ merged["NEXO_HEADLESS"] = "1"
73
+ merged.pop("CLAUDECODE", None)
74
+ merged.pop("CLAUDE_CODE", None)
75
+ return merged
76
+
77
+
78
+ def build_interactive_client_command(
79
+ *,
80
+ target: str | os.PathLike[str],
81
+ client: str | None = None,
82
+ preferences: dict | None = None,
83
+ ) -> tuple[str, list[str]]:
84
+ prefs = preferences or load_client_preferences()
85
+ selected = resolve_terminal_client(client, preferences=prefs)
86
+ target_path = str(Path(target).expanduser())
87
+ profile = resolve_client_runtime_profile(selected, preferences=prefs)
88
+
89
+ if selected == CLIENT_CLAUDE_CODE:
90
+ claude_bin = _resolve_claude_cli()
91
+ if not claude_bin:
92
+ raise TerminalClientUnavailableError(
93
+ "Claude Code launcher not found in PATH. Install `claude` first."
94
+ )
95
+ cmd = [claude_bin]
96
+ if profile["model"]:
97
+ cmd.extend(["--model", profile["model"]])
98
+ if profile["reasoning_effort"]:
99
+ cmd.extend(["--effort", profile["reasoning_effort"]])
100
+ cmd.extend(["--dangerously-skip-permissions", target_path])
101
+ return selected, cmd
102
+
103
+ if selected == CLIENT_CODEX:
104
+ codex_bin = _resolve_codex_cli()
105
+ if not codex_bin:
106
+ raise TerminalClientUnavailableError(
107
+ "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
108
+ )
109
+ cmd = [codex_bin]
110
+ if profile["model"]:
111
+ cmd.extend(["-m", profile["model"]])
112
+ if profile["reasoning_effort"]:
113
+ cmd.extend(["-c", f'model_reasoning_effort="{profile["reasoning_effort"]}"'])
114
+ cmd.extend(["-C", target_path])
115
+ return selected, cmd
116
+
117
+ raise TerminalClientUnavailableError(f"Unsupported terminal client: {selected}")
118
+
119
+
120
+ def launch_interactive_client(
121
+ *,
122
+ target: str | os.PathLike[str],
123
+ client: str | None = None,
124
+ env: dict | None = None,
125
+ preferences: dict | None = None,
126
+ ) -> subprocess.CompletedProcess:
127
+ _, cmd = build_interactive_client_command(target=target, client=client, preferences=preferences)
128
+ launch_env = os.environ.copy()
129
+ if env:
130
+ launch_env.update(env)
131
+ return subprocess.run(cmd, env=launch_env)
132
+
133
+
134
+ def _resolve_runtime_model_and_effort(
135
+ client: str,
136
+ *,
137
+ model: str | None = None,
138
+ reasoning_effort: str | None = None,
139
+ preferences: dict | None = None,
140
+ ) -> tuple[str, str]:
141
+ profile = resolve_client_runtime_profile(client, preferences=preferences)
142
+ requested_model = str(model or "").strip()
143
+ requested_effort = str(reasoning_effort or "").strip().lower()
144
+
145
+ if client == CLIENT_CODEX:
146
+ if not requested_model or requested_model.lower() in CLAUDE_LEGACY_MODEL_HINTS:
147
+ requested_model = profile["model"]
148
+ elif not requested_model:
149
+ requested_model = profile["model"]
150
+
151
+ if not requested_effort:
152
+ requested_effort = profile["reasoning_effort"]
153
+
154
+ return requested_model, requested_effort
155
+
156
+
157
+ def _build_codex_prompt(
158
+ prompt: str,
159
+ *,
160
+ output_format: str = "",
161
+ append_system_prompt: str = "",
162
+ allowed_tools: str = "",
163
+ ) -> str:
164
+ instructions: list[str] = []
165
+ if append_system_prompt:
166
+ instructions.append(f"SYSTEM INSTRUCTIONS:\n{append_system_prompt}")
167
+ if output_format and output_format.lower() == "text":
168
+ instructions.append("FINAL RESPONSE FORMAT: plain text only.")
169
+ elif output_format:
170
+ instructions.append(f"FINAL RESPONSE FORMAT: {output_format}.")
171
+ if allowed_tools:
172
+ instructions.append(
173
+ "TOOLING SCOPE: Prefer to stay within capabilities equivalent to "
174
+ f"{allowed_tools} unless that would make the task fail."
175
+ )
176
+ if instructions:
177
+ return "\n\n".join([*instructions, prompt])
178
+ return prompt
179
+
180
+
181
+ def run_automation_prompt(
182
+ prompt: str,
183
+ *,
184
+ backend: str | None = None,
185
+ cwd: str | os.PathLike[str] | None = None,
186
+ env: dict | None = None,
187
+ model: str = "",
188
+ reasoning_effort: str = "",
189
+ timeout: int = 300,
190
+ output_format: str = "",
191
+ append_system_prompt: str = "",
192
+ allowed_tools: str = "",
193
+ extra_args: list[str] | tuple[str, ...] | None = None,
194
+ ) -> subprocess.CompletedProcess:
195
+ prefs = load_client_preferences()
196
+ selected_backend = backend or resolve_automation_backend(preferences=prefs)
197
+ if selected_backend == BACKEND_NONE:
198
+ raise AutomationBackendUnavailableError("Automation backend is disabled in config.")
199
+
200
+ cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
201
+ run_env = _headless_env(env)
202
+ extra_args = list(extra_args or [])
203
+ resolved_model, resolved_effort = _resolve_runtime_model_and_effort(
204
+ selected_backend,
205
+ model=model,
206
+ reasoning_effort=reasoning_effort,
207
+ preferences=prefs,
208
+ )
209
+
210
+ if selected_backend == CLIENT_CLAUDE_CODE:
211
+ claude_bin = _resolve_claude_cli()
212
+ if not claude_bin:
213
+ raise AutomationBackendUnavailableError(
214
+ "Claude Code automation backend selected but `claude` is not installed."
215
+ )
216
+ cmd = [claude_bin, "-p", prompt]
217
+ if resolved_model:
218
+ cmd.extend(["--model", resolved_model])
219
+ if resolved_effort:
220
+ cmd.extend(["--effort", resolved_effort])
221
+ if output_format:
222
+ cmd.extend(["--output-format", output_format])
223
+ if append_system_prompt:
224
+ cmd.extend(["--append-system-prompt", append_system_prompt])
225
+ if allowed_tools:
226
+ cmd.extend(["--allowedTools", allowed_tools])
227
+ cmd.extend(extra_args)
228
+ return subprocess.run(
229
+ cmd,
230
+ cwd=str(cwd_path),
231
+ capture_output=True,
232
+ text=True,
233
+ timeout=timeout,
234
+ env=run_env,
235
+ )
236
+
237
+ if selected_backend == CLIENT_CODEX:
238
+ codex_bin = _resolve_codex_cli()
239
+ if not codex_bin:
240
+ raise AutomationBackendUnavailableError(
241
+ "Codex automation backend selected but `codex` is not installed."
242
+ )
243
+ with tempfile.TemporaryDirectory(prefix="nexo-codex-") as tmpdir:
244
+ output_path = Path(tmpdir) / "last-message.txt"
245
+ cmd = [
246
+ codex_bin,
247
+ "exec",
248
+ "--skip-git-repo-check",
249
+ "--dangerously-bypass-approvals-and-sandbox",
250
+ "--ephemeral",
251
+ "-C",
252
+ str(cwd_path),
253
+ "-o",
254
+ str(output_path),
255
+ ]
256
+ if resolved_model:
257
+ cmd.extend(["-m", resolved_model])
258
+ if resolved_effort:
259
+ cmd.extend(["-c", f'model_reasoning_effort="{resolved_effort}"'])
260
+ cmd.extend(extra_args)
261
+ cmd.append(
262
+ _build_codex_prompt(
263
+ prompt,
264
+ output_format=output_format,
265
+ append_system_prompt=append_system_prompt,
266
+ allowed_tools=allowed_tools,
267
+ )
268
+ )
269
+ result = subprocess.run(
270
+ cmd,
271
+ cwd=str(cwd_path),
272
+ capture_output=True,
273
+ text=True,
274
+ timeout=timeout,
275
+ env=run_env,
276
+ )
277
+ stdout = output_path.read_text() if output_path.exists() else (result.stdout or "")
278
+ return subprocess.CompletedProcess(
279
+ cmd,
280
+ result.returncode,
281
+ stdout,
282
+ result.stderr,
283
+ )
284
+
285
+ raise AutomationBackendUnavailableError(f"Unsupported automation backend: {selected_backend}")
286
+
287
+
288
+ def probe_automation_backend(
289
+ *,
290
+ backend: str | None = None,
291
+ cwd: str | os.PathLike[str] | None = None,
292
+ timeout: int = 60,
293
+ ) -> dict:
294
+ selected_backend = backend or resolve_automation_backend()
295
+ if selected_backend == BACKEND_NONE:
296
+ return {
297
+ "ok": False,
298
+ "backend": BACKEND_NONE,
299
+ "reason": "automation disabled in config",
300
+ }
301
+ try:
302
+ result = run_automation_prompt(
303
+ "Reply exactly OK.",
304
+ backend=selected_backend,
305
+ cwd=cwd,
306
+ timeout=timeout,
307
+ output_format="text",
308
+ )
309
+ except AutomationBackendUnavailableError as exc:
310
+ return {
311
+ "ok": False,
312
+ "backend": selected_backend,
313
+ "reason": str(exc),
314
+ }
315
+ output = (result.stdout or "").strip()
316
+ return {
317
+ "ok": result.returncode == 0 and "OK" in output,
318
+ "backend": selected_backend,
319
+ "returncode": result.returncode,
320
+ "stdout": output,
321
+ "stderr": (result.stderr or "").strip(),
322
+ }
@@ -933,7 +933,7 @@ def auto_update_check() -> dict:
933
933
 
934
934
  # Backfill runtime CLI modules for existing installs
935
935
  try:
936
- for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py"):
936
+ for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "agent_runner.py"):
937
937
  src_file = SRC_DIR / fname
938
938
  dest_file = NEXO_HOME / fname
939
939
  if src_file.is_file() and (not dest_file.exists() or src_file.stat().st_mtime > dest_file.stat().st_mtime):
@@ -1198,6 +1198,7 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1198
1198
  "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
1199
1199
  "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
1200
1200
  "client_sync.py",
1201
+ "client_preferences.py", "agent_runner.py",
1201
1202
  "auto_update.py", "tools_sessions.py", "tools_coordination.py",
1202
1203
  "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1203
1204
  "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
@@ -1247,6 +1248,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1247
1248
  "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
1248
1249
  "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
1249
1250
  "client_sync.py",
1251
+ "client_preferences.py", "agent_runner.py",
1250
1252
  "auto_update.py", "tools_sessions.py", "tools_coordination.py",
1251
1253
  "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1252
1254
  "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
@@ -1446,8 +1448,15 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
1446
1448
  _emit_progress(progress_fn, "Refreshing shared client configs...")
1447
1449
  try:
1448
1450
  from client_sync import sync_all_clients
1449
-
1450
- client_sync_result = sync_all_clients(nexo_home=dest, runtime_root=dest)
1451
+ from client_preferences import normalize_client_preferences
1452
+
1453
+ schedule_path = dest / "config" / "schedule.json"
1454
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
1455
+ client_sync_result = sync_all_clients(
1456
+ nexo_home=dest,
1457
+ runtime_root=dest,
1458
+ preferences=normalize_client_preferences(schedule_payload),
1459
+ )
1451
1460
  if client_sync_result.get("ok"):
1452
1461
  actions.append("client-sync")
1453
1462
  else:
package/src/cli.py CHANGED
@@ -846,10 +846,6 @@ def _dashboard(args):
846
846
 
847
847
  def _chat(args):
848
848
  target = args.path or "."
849
- claude_bin = os.environ.get("CLAUDE_BIN") or shutil.which("claude")
850
- if not claude_bin:
851
- print("Claude Code launcher not found in PATH. Install `claude` first.", file=sys.stderr)
852
- return 1
853
849
 
854
850
  try:
855
851
  from auto_update import startup_preflight
@@ -868,10 +864,21 @@ def _chat(args):
868
864
  except Exception:
869
865
  pass
870
866
 
871
- result = subprocess.run(
872
- [claude_bin, "--dangerously-skip-permissions", target],
873
- env=os.environ.copy(),
874
- )
867
+ try:
868
+ from agent_runner import TerminalClientUnavailableError, launch_interactive_client
869
+ except ImportError:
870
+ print("Agent runner module not found. Ensure NEXO is properly installed.", file=sys.stderr)
871
+ return 1
872
+
873
+ try:
874
+ result = launch_interactive_client(
875
+ target=target,
876
+ client=getattr(args, "client", None),
877
+ env=os.environ.copy(),
878
+ )
879
+ except TerminalClientUnavailableError as exc:
880
+ print(str(exc), file=sys.stderr)
881
+ return 1
875
882
  return int(result.returncode)
876
883
 
877
884
 
@@ -1005,7 +1012,7 @@ def _print_help():
1005
1012
  print(f"""NEXO Runtime CLI v{v}
1006
1013
 
1007
1014
  Commands:
1008
- nexo chat [path] Launch Claude Code
1015
+ nexo chat [path] [--client claude_code|codex] Launch the selected terminal client
1009
1016
  nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
1010
1017
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
1011
1018
  Personal scripts
@@ -1027,8 +1034,13 @@ def main():
1027
1034
  sub = parser.add_subparsers(dest="command")
1028
1035
 
1029
1036
  # -- chat --
1030
- chat_parser = sub.add_parser("chat", help="Launch Claude Code")
1037
+ chat_parser = sub.add_parser("chat", help="Launch the selected terminal client")
1031
1038
  chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
1039
+ chat_parser.add_argument(
1040
+ "--client",
1041
+ choices=["claude_code", "codex"],
1042
+ help="Override the configured default terminal client",
1043
+ )
1032
1044
 
1033
1045
  # -- scripts --
1034
1046
  scripts_parser = sub.add_parser("scripts", help="Manage personal scripts")