nexo-brain 7.12.0 → 7.12.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.0",
3
+ "version": "7.12.2",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,9 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.12.0` is the current packaged-runtime line. Minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The new support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails. In parallel, map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn, and the headless enforcer upgrades any legacy `Do not produce visible text` copy defensively at enqueue time. Result: orphan assistant text such as “Esperando…” no longer leaks into Desktop after background protocol reminders with no fresh user message.
21
+ Version `7.12.2` is the current packaged-runtime line. Patch release — legacy headless automation paths now stay on the resonance engine: `task_profile` no longer pre-fills model/effort, `email-monitor` stops carrying a private routing override, personal automation helpers stop injecting a default model, and runtime updates scrub the last stale email-profile field automatically. Result: email daemon, personal scripts, and updated installs all converge on the same `caller`/`tier` backend `(model, effort)` resolution path already used by Deep Sleep and morning-agent.
22
22
 
23
- Previously in `7.11.6`: patch release — Guardian G4 now filters more false-positive slash fragments before they become debt, `strict_protocol_write_without_task` downgrades to `warn` when the session has a fresh heartbeat, and Deep Sleep extraction validates the real prompt contract instead of accepting any syntactically valid JSON. Validation so far: `50` targeted tests across hook guardrails and Deep Sleep extraction.
23
+ Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
24
24
 
25
25
  Previously in `7.11.5`: patch release — Desktop-managed installs now block the standalone dashboard at the same product-mode layer as evolution, so `installation_live`, cron sync, and watchdog no longer disagree about whether `com.nexo.dashboard` should exist. Validation: `125` targeted tests across product-mode, cron sync, and doctor, plus a full pre-release wrapper (`2321 passed, 2 skipped, 1 xfailed, 4 xpassed`).
26
26
 
package/bin/nexo-brain.js CHANGED
@@ -17,8 +17,21 @@
17
17
  const { execSync, spawnSync } = require("child_process");
18
18
  const crypto = require("crypto");
19
19
  const fs = require("fs");
20
+ const { createRequire } = require("module");
20
21
  const path = require("path");
21
22
  const readline = require("readline");
23
+ // Force relative launcher helpers to resolve from bin/ even under test harnesses.
24
+ require = createRequire(path.join(__dirname, "nexo-brain.js"));
25
+ const { runViaWsl } = require("./windows-wsl-bridge");
26
+
27
+ if (process.platform === "win32") {
28
+ const bridged = runViaWsl({
29
+ scriptPath: __filename,
30
+ args: process.argv.slice(2),
31
+ label: "NEXO Brain",
32
+ });
33
+ process.exit(bridged?.status ?? 1);
34
+ }
22
35
 
23
36
  let NEXO_HOME = process.env.NEXO_HOME || path.join(require("os").homedir(), ".nexo");
24
37
  const DEFAULT_ASSISTANT_NAME = "Nova";
@@ -2425,9 +2438,9 @@ async function runSetup() {
2425
2438
  // Check prerequisites
2426
2439
  const platform = process.platform;
2427
2440
  if (platform === "win32") {
2428
- log("Windows detected. NEXO Brain requires WSL (Windows Subsystem for Linux).");
2441
+ log("Windows detected, but the automatic WSL bridge was not available.");
2429
2442
  log("Install WSL: https://learn.microsoft.com/en-us/windows/wsl/install");
2430
- log("Then run this command inside WSL (Ubuntu terminal), not PowerShell/CMD.");
2443
+ log("Then run this command again, or launch it directly inside WSL (Ubuntu terminal).");
2431
2444
  process.exit(1);
2432
2445
  }
2433
2446
  if (platform !== "darwin" && platform !== "linux") {
package/bin/nexo.js CHANGED
@@ -11,6 +11,16 @@ const { spawnSync } = require("child_process");
11
11
  const fs = require("fs");
12
12
  const os = require("os");
13
13
  const path = require("path");
14
+ const { runViaWsl } = require("./windows-wsl-bridge");
15
+
16
+ if (process.platform === "win32") {
17
+ const bridged = runViaWsl({
18
+ scriptPath: __filename,
19
+ args: process.argv.slice(2),
20
+ label: "NEXO CLI",
21
+ });
22
+ process.exit(bridged?.status ?? 1);
23
+ }
14
24
 
15
25
  function resolveNexoHome(rawValue) {
16
26
  const homeDir = os.homedir();
@@ -0,0 +1,162 @@
1
+ const { spawnSync } = require("child_process");
2
+
3
+ function isWindowsHost(platform = process.platform) {
4
+ return platform === "win32";
5
+ }
6
+
7
+ function isWindowsStylePath(value) {
8
+ const text = String(value || "").trim();
9
+ if (!text) return false;
10
+ return /^[A-Za-z]:[\\/]/.test(text) || text.startsWith("\\\\");
11
+ }
12
+
13
+ function parseWslUncPath(rawValue) {
14
+ const value = String(rawValue || "").trim();
15
+ const lowered = value.toLowerCase();
16
+ if (!lowered.startsWith("\\\\wsl$\\") && !lowered.startsWith("\\\\wsl.localhost\\")) {
17
+ return null;
18
+ }
19
+ const parts = value.split("\\").filter(Boolean);
20
+ if (parts.length < 3) return null;
21
+ return {
22
+ distro: parts[1],
23
+ linuxPath: `/${parts.slice(2).join("/")}`,
24
+ };
25
+ }
26
+
27
+ function toWslPath(rawValue) {
28
+ const value = String(rawValue || "").trim();
29
+ if (!value) return "";
30
+ if (value.startsWith("/")) return value;
31
+ const unc = parseWslUncPath(value);
32
+ if (unc) return unc.linuxPath;
33
+
34
+ const normalized = value.replace(/\\/g, "/");
35
+ const driveMatch = normalized.match(/^([A-Za-z]):(\/.*)?$/);
36
+ if (driveMatch) {
37
+ return `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] || ""}`;
38
+ }
39
+ if (normalized.startsWith("//")) {
40
+ return "";
41
+ }
42
+ return normalized;
43
+ }
44
+
45
+ function resolveExplicitLinuxPath(rawValue) {
46
+ const value = String(rawValue || "").trim();
47
+ if (!value) return "";
48
+ if (value.startsWith("/")) return value;
49
+ if (isWindowsStylePath(value)) return toWslPath(value);
50
+ return value;
51
+ }
52
+
53
+ function resolveLinuxEnv(env = process.env) {
54
+ const linuxEnv = {
55
+ NEXO_WINDOWS_BRIDGE: "1",
56
+ NEXO_WINDOWS_HOST: "1",
57
+ };
58
+
59
+ const explicitHome = resolveExplicitLinuxPath(env.NEXO_WSL_HOME);
60
+ if (explicitHome) {
61
+ linuxEnv.NEXO_HOME = explicitHome;
62
+ } else if (env.NEXO_HOME && !isWindowsStylePath(env.NEXO_HOME)) {
63
+ linuxEnv.NEXO_HOME = String(env.NEXO_HOME).trim();
64
+ }
65
+
66
+ const explicitCode = resolveExplicitLinuxPath(env.NEXO_WSL_CODE);
67
+ if (explicitCode) {
68
+ linuxEnv.NEXO_CODE = explicitCode;
69
+ } else if (env.NEXO_CODE && !isWindowsStylePath(env.NEXO_CODE)) {
70
+ linuxEnv.NEXO_CODE = String(env.NEXO_CODE).trim();
71
+ }
72
+
73
+ return linuxEnv;
74
+ }
75
+
76
+ function resolveWslNodeBinary(env = process.env) {
77
+ const explicitNode = resolveExplicitLinuxPath(env.NEXO_WSL_NODE);
78
+ return explicitNode || "node";
79
+ }
80
+
81
+ function resolveWslDistro(scriptPath, env = process.env) {
82
+ const explicitDistro = String(env.NEXO_WSL_DISTRO || "").trim();
83
+ if (explicitDistro) return explicitDistro;
84
+ const unc = parseWslUncPath(scriptPath);
85
+ return unc ? unc.distro : "";
86
+ }
87
+
88
+ function buildWslExecSpec({ scriptPath, args = [], env = process.env, platform = process.platform }) {
89
+ if (!isWindowsHost(platform)) return null;
90
+ const translatedScriptPath = toWslPath(scriptPath);
91
+ if (!translatedScriptPath) {
92
+ return {
93
+ error: `Unable to translate Windows path to WSL path: ${scriptPath}`,
94
+ };
95
+ }
96
+
97
+ const linuxEnv = resolveLinuxEnv(env);
98
+ const wslArgs = [];
99
+ const distro = resolveWslDistro(scriptPath, env);
100
+ if (distro) {
101
+ wslArgs.push("-d", distro);
102
+ }
103
+
104
+ wslArgs.push(
105
+ "--exec",
106
+ "env",
107
+ "-u",
108
+ "NEXO_HOME",
109
+ "-u",
110
+ "NEXO_CODE",
111
+ "-u",
112
+ "NEXO_WSL_HOME",
113
+ "-u",
114
+ "NEXO_WSL_CODE"
115
+ );
116
+
117
+ for (const [key, value] of Object.entries(linuxEnv)) {
118
+ wslArgs.push(`${key}=${value}`);
119
+ }
120
+
121
+ wslArgs.push(resolveWslNodeBinary(env), translatedScriptPath, ...args);
122
+
123
+ return {
124
+ command: "wsl.exe",
125
+ args: wslArgs,
126
+ linuxEnv,
127
+ translatedScriptPath,
128
+ };
129
+ }
130
+
131
+ function logWslBridgeFailure(label, message) {
132
+ console.error(`${label} could not start through WSL.`);
133
+ console.error(message);
134
+ console.error("Install WSL: https://learn.microsoft.com/en-us/windows/wsl/install");
135
+ console.error("Then run the same command again from PowerShell/CMD or inside your Ubuntu WSL shell.");
136
+ }
137
+
138
+ function runViaWsl({ scriptPath, args = [], env = process.env, platform = process.platform, stdio = "inherit", label = "NEXO" }) {
139
+ const spec = buildWslExecSpec({ scriptPath, args, env, platform });
140
+ if (!spec) return null;
141
+ if (spec.error) {
142
+ logWslBridgeFailure(label, spec.error);
143
+ return { status: 1 };
144
+ }
145
+
146
+ const result = spawnSync(spec.command, spec.args, { stdio, env });
147
+ if (result.error && result.error.code === "ENOENT") {
148
+ logWslBridgeFailure(label, "The Windows host does not have `wsl.exe` available.");
149
+ return { status: 1 };
150
+ }
151
+ return result;
152
+ }
153
+
154
+ module.exports = {
155
+ buildWslExecSpec,
156
+ isWindowsHost,
157
+ isWindowsStylePath,
158
+ parseWslUncPath,
159
+ resolveLinuxEnv,
160
+ runViaWsl,
161
+ toWslPath,
162
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.0",
3
+ "version": "7.12.2",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -73,6 +73,7 @@
73
73
  "files": [
74
74
  "bin/nexo-brain.js",
75
75
  "bin/nexo.js",
76
+ "bin/windows-wsl-bridge.js",
76
77
  "bin/postinstall.js",
77
78
  "scripts/sync_release_artifacts.py",
78
79
  "src/",
@@ -27,7 +27,6 @@ from client_preferences import (
27
27
  load_client_preferences,
28
28
  normalize_client_key,
29
29
  resolve_automation_backend,
30
- resolve_automation_task_profile,
31
30
  resolve_client_runtime_profile,
32
31
  resolve_terminal_client,
33
32
  )
@@ -936,14 +935,6 @@ def run_automation_prompt(
936
935
  selected_backend = backend or resolve_automation_backend(preferences=prefs)
937
936
  if selected_backend == BACKEND_NONE:
938
937
  raise AutomationBackendUnavailableError("Automation backend is disabled in config.")
939
-
940
- if task_profile:
941
- profile = resolve_automation_task_profile(task_profile, preferences=prefs)
942
- selected_backend = profile["backend"] or selected_backend
943
- if not model:
944
- model = profile["model"]
945
- if not reasoning_effort:
946
- reasoning_effort = profile["reasoning_effort"]
947
938
  selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
948
939
 
949
940
  # Resonance map decides (model, effort) for every call. ``caller`` is
@@ -1630,6 +1630,47 @@ def _migrate_effort_to_resonance(dest: Path = NEXO_HOME) -> list[str]:
1630
1630
  return actions
1631
1631
 
1632
1632
 
1633
+ def _cleanup_legacy_email_routing_config(dest: Path = NEXO_HOME) -> list[str]:
1634
+ """Remove retired email routing overrides from persisted runtime config.
1635
+
1636
+ Older runtimes stored ``automation_task_profile`` inside
1637
+ ``runtime/nexo-email/config.json`` (and earlier under ``nexo-email/``).
1638
+ The email monitor now resolves model/effort exclusively from its caller in
1639
+ ``resonance_map.py``, so keeping that key around only preserves a stale,
1640
+ misleading second source of truth.
1641
+ """
1642
+ import json as _json
1643
+
1644
+ actions: list[str] = []
1645
+ candidates = [
1646
+ dest / "runtime" / "nexo-email" / "config.json",
1647
+ dest / "nexo-email" / "config.json",
1648
+ ]
1649
+ for path in candidates:
1650
+ if not path.is_file():
1651
+ continue
1652
+ try:
1653
+ payload = _json.loads(path.read_text())
1654
+ except Exception as exc:
1655
+ actions.append(
1656
+ f"email-routing-cleanup-warning:{path.name}:{exc.__class__.__name__}"
1657
+ )
1658
+ continue
1659
+ if not isinstance(payload, dict):
1660
+ continue
1661
+ if "automation_task_profile" not in payload:
1662
+ continue
1663
+ payload.pop("automation_task_profile", None)
1664
+ try:
1665
+ path.write_text(_json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
1666
+ actions.append(f"email-routing-cleanup:{path}")
1667
+ except Exception as exc:
1668
+ actions.append(
1669
+ f"email-routing-cleanup-warning:{path.name}:{exc.__class__.__name__}"
1670
+ )
1671
+ return actions
1672
+
1673
+
1633
1674
  def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
1634
1675
  """Ensure ``resonance_tiers.json`` lives at the public contract path
1635
1676
  ``NEXO_HOME/personal/brain/resonance_tiers.json`` and purge the legacy
@@ -4688,6 +4729,14 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4688
4729
  except Exception as exc:
4689
4730
  actions.append(f"v6-purge-warning:{exc.__class__.__name__}")
4690
4731
 
4732
+ try:
4733
+ _emit_progress(progress_fn, "Cleaning legacy email routing overrides...")
4734
+ email_actions = _cleanup_legacy_email_routing_config(dest)
4735
+ for action in email_actions:
4736
+ actions.append(action)
4737
+ except Exception as exc:
4738
+ actions.append(f"email-routing-cleanup-warning:{exc.__class__.__name__}")
4739
+
4691
4740
  try:
4692
4741
  _emit_progress(progress_fn, "Ensuring local classifier baseline...")
4693
4742
  if _is_ephemeral_runtime_install(dest):
@@ -59,8 +59,12 @@ DEFAULT_CLAUDE_CODE_MODEL = _CLAUDE_DEFAULTS["model"]
59
59
  DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _CLAUDE_DEFAULTS["reasoning_effort"]
60
60
  DEFAULT_CODEX_MODEL = _CODEX_DEFAULTS["model"]
61
61
  DEFAULT_CODEX_REASONING_EFFORT = _CODEX_DEFAULTS["reasoning_effort"]
62
- DEFAULT_FAST_MODEL = DEFAULT_CLAUDE_CODE_MODEL
63
- DEFAULT_FAST_REASONING_EFFORT = ""
62
+ AUTOMATION_TASK_PROFILE_TIERS = {
63
+ "default": "",
64
+ "fast": "bajo",
65
+ "balanced": "medio",
66
+ "deep": "maximo",
67
+ }
64
68
 
65
69
 
66
70
  def _user_home() -> Path:
@@ -385,9 +389,9 @@ def default_automation_task_profiles() -> dict[str, dict[str, str]]:
385
389
  "reasoning_effort": "",
386
390
  },
387
391
  "deep": {
388
- "backend": CLIENT_CLAUDE_CODE,
389
- "model": DEFAULT_CLAUDE_CODE_MODEL,
390
- "reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
392
+ "backend": "",
393
+ "model": "",
394
+ "reasoning_effort": "",
391
395
  },
392
396
  }
393
397
 
@@ -443,16 +447,10 @@ def normalize_automation_task_profiles(value) -> dict[str, dict[str, str]]:
443
447
  continue
444
448
  if not isinstance(raw_value, dict):
445
449
  continue
446
- backend = normalize_backend_key(raw_value.get("backend"))
447
- if backend == BACKEND_NONE:
448
- backend = ""
449
- normalized[profile_key] = {
450
- "backend": backend or defaults[profile_key]["backend"],
451
- "model": str(raw_value.get("model") or defaults[profile_key]["model"]).strip(),
452
- "reasoning_effort": str(
453
- raw_value.get("reasoning_effort") or defaults[profile_key]["reasoning_effort"]
454
- ).strip().lower(),
455
- }
450
+ # These profiles are no longer allowed to route backend/model/effort.
451
+ # Older installs may still persist those keys; normalize drops them
452
+ # so schedule.json cannot bypass resonance resolution silently.
453
+ normalized[profile_key] = dict(defaults[profile_key])
456
454
  return normalized
457
455
 
458
456
 
@@ -769,18 +767,13 @@ def resolve_automation_task_profile(
769
767
  *,
770
768
  preferences: dict | None = None,
771
769
  ) -> dict[str, str]:
772
- normalized = preferences or load_client_preferences()
773
- defaults = default_automation_task_profiles()
774
770
  profile_key = str(profile or "").strip().lower() or "default"
775
771
  if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
776
772
  profile_key = "default"
777
- configured = normalize_automation_task_profiles(normalized.get("automation_task_profiles"))
778
- selected = dict(configured.get(profile_key) or defaults[profile_key])
779
- backend = selected.get("backend") or resolve_automation_backend(normalized)
780
- runtime_profile = resolve_client_runtime_profile(backend, preferences=normalized)
781
773
  return {
782
774
  "name": profile_key,
783
- "backend": backend,
784
- "model": selected.get("model") or runtime_profile["model"],
785
- "reasoning_effort": selected.get("reasoning_effort") or runtime_profile["reasoning_effort"],
775
+ "backend": "",
776
+ "model": "",
777
+ "reasoning_effort": "",
778
+ "tier": AUTOMATION_TASK_PROFILE_TIERS.get(profile_key, ""),
786
779
  }
@@ -95,7 +95,6 @@ def _account_to_runtime_account(account: dict) -> dict:
95
95
  "retry_backoff_seconds": metadata.get("retry_backoff_seconds", 60),
96
96
  "claude_binary": metadata.get("claude_binary", ""),
97
97
  "working_dir": metadata.get("working_dir", str(Path.home())),
98
- "automation_task_profile": metadata.get("automation_task_profile", "deep"),
99
98
  "max_process_time": metadata.get("max_process_time"),
100
99
  "metadata": metadata,
101
100
  }
@@ -139,7 +138,6 @@ def _account_to_legacy_shape(
139
138
  "retry_backoff_seconds": runtime_account["retry_backoff_seconds"],
140
139
  "claude_binary": runtime_account["claude_binary"],
141
140
  "working_dir": runtime_account["working_dir"],
142
- "automation_task_profile": runtime_account["automation_task_profile"],
143
141
  "max_process_time": runtime_account["max_process_time"],
144
142
  "label": runtime_account["label"],
145
143
  "role": runtime_account["role"],
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  import json
19
19
  import os
20
+ import platform
20
21
  import re
21
22
  import sqlite3
22
23
  import subprocess
@@ -24,6 +25,8 @@ import time
24
25
  from pathlib import Path
25
26
  from typing import Any
26
27
 
28
+ from windows_runtime import running_inside_wsl, windows_runtime_status
29
+
27
30
 
28
31
  def _nexo_home() -> Path:
29
32
  return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
@@ -31,8 +34,16 @@ def _nexo_home() -> Path:
31
34
 
32
35
  def _check_runtime() -> dict:
33
36
  home = _nexo_home()
37
+ system = platform.system()
38
+ release = platform.release()
39
+ windows_status = windows_runtime_status(home, system=system, release=release)
34
40
  ver_file = home / "version.json"
35
- out: dict[str, Any] = {"nexo_home": str(home), "exists": home.is_dir()}
41
+ out: dict[str, Any] = {
42
+ "nexo_home": str(home),
43
+ "exists": home.is_dir(),
44
+ "is_wsl": running_inside_wsl(system=system, release=release),
45
+ "windows_runtime": windows_status,
46
+ }
36
47
  if ver_file.is_file():
37
48
  try:
38
49
  payload = json.loads(ver_file.read_text())
@@ -43,6 +54,8 @@ def _check_runtime() -> dict:
43
54
  else:
44
55
  out["version"] = "missing"
45
56
  out["status"] = "ok" if out["exists"] and out.get("version") not in ("missing", "unreadable") else "degraded"
57
+ if windows_status["warnings"]:
58
+ out["status"] = "degraded"
46
59
  return out
47
60
 
48
61
 
@@ -191,6 +191,14 @@ _SFTP_BATCH_RE = re.compile(
191
191
  r"\bsftp\b(?:[^|&;]*\s)?-b\s+\S+",
192
192
  re.IGNORECASE,
193
193
  )
194
+ _SSH_REMOTE_PIPE_RE = re.compile(
195
+ r"\|\s*ssh\b",
196
+ re.IGNORECASE,
197
+ )
198
+ _SSH_REMOTE_STDIN_RE = re.compile(
199
+ r"\bssh\b[^\n|&;]*(?:<\s*\S+|<<-?\s*(?:['\"]?[A-Za-z0-9_]+['\"]?))",
200
+ re.IGNORECASE,
201
+ )
194
202
 
195
203
 
196
204
  def _classify_ssh_remote_write(command: str) -> str | None:
@@ -231,6 +239,10 @@ def _classify_ssh_remote_write(command: str) -> str | None:
231
239
  for pattern in _SSH_REMOTE_WRITE_VERBS:
232
240
  if pattern.search(trimmed):
233
241
  return "ssh_remote_shell_write"
242
+ if _SSH_REMOTE_PIPE_RE.search(cmd):
243
+ return "ssh_remote_shell_write"
244
+ if _SSH_REMOTE_STDIN_RE.search(cmd):
245
+ return "ssh_remote_shell_write"
234
246
  return None
235
247
 
236
248
 
@@ -270,6 +282,64 @@ _PATH_ARTIFACT_RE = re.compile(
270
282
  )
271
283
  _DATE_LIKE_PATH_RE = re.compile(r"^/\d{1,4}/\d{1,4}(?:/\d{1,4})?$")
272
284
  _STRICT_WRITE_HEARTBEAT_WINDOW_SECONDS = 300
285
+ _G3_CORTEX_AUTH_WINDOW_SECONDS = max(
286
+ 60,
287
+ int(os.environ.get("NEXO_G3_CORTEX_AUTH_WINDOW_SECONDS", "900")),
288
+ )
289
+ _CORTEX_NEGATIVE_TOKENS = (
290
+ "abort",
291
+ "avoid",
292
+ "block",
293
+ "cancel",
294
+ "decline",
295
+ "defer",
296
+ "deny",
297
+ "do_not",
298
+ "dont",
299
+ "no_",
300
+ "not_now",
301
+ "reject",
302
+ "skip",
303
+ "wait",
304
+ )
305
+ _G3_CORTEX_GENERIC_APPROVAL_TOKENS = (
306
+ "allow",
307
+ "apply",
308
+ "approve",
309
+ "continue",
310
+ "deploy",
311
+ "execute",
312
+ "go_ahead",
313
+ "proceed",
314
+ "publish",
315
+ "retry",
316
+ "run",
317
+ )
318
+ _G3_CORTEX_FAMILY_TOKENS = {
319
+ "destructive": (
320
+ "chmod",
321
+ "cleanup",
322
+ "delete",
323
+ "drop",
324
+ "force",
325
+ "git_push",
326
+ "purge",
327
+ "remove",
328
+ "rm",
329
+ "truncate",
330
+ "wipe",
331
+ ),
332
+ "ssh": (
333
+ "deploy",
334
+ "remote",
335
+ "rsync",
336
+ "scp",
337
+ "sftp",
338
+ "ssh",
339
+ "sync",
340
+ "upload",
341
+ ),
342
+ }
273
343
 
274
344
  # Single-segment ``/word`` candidates that match a small dictionary block-list
275
345
  # of confirmed false positives observed in the live debt log.
@@ -655,6 +725,7 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
655
725
  target = _normalize_file_path(filepath)
656
726
  rows = conn.execute(
657
727
  """SELECT task_id, files, guard_has_blocking, guard_acknowledged, task_type, plan, unknowns,
728
+ opened_at,
658
729
  verification_step, opened_with_guard, must_change_log, must_verify
659
730
  FROM protocol_tasks
660
731
  WHERE session_id = ? AND status = 'open'
@@ -675,6 +746,7 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
675
746
  def _find_any_open_task(conn, sid: str) -> dict | None:
676
747
  row = conn.execute(
677
748
  """SELECT task_id, files, guard_has_blocking, guard_acknowledged, task_type, plan, unknowns,
749
+ opened_at,
678
750
  verification_step, opened_with_guard, must_change_log, must_verify
679
751
  FROM protocol_tasks
680
752
  WHERE session_id = ? AND status = 'open'
@@ -685,6 +757,86 @@ def _find_any_open_task(conn, sid: str) -> dict | None:
685
757
  return dict(row) if row else None
686
758
 
687
759
 
760
+ def _normalize_cortex_tokens(value: str) -> str:
761
+ return re.sub(r"[^a-z0-9]+", "_", str(value or "").lower()).strip("_")
762
+
763
+
764
+ def _text_has_any_token(value: str, tokens: tuple[str, ...]) -> bool:
765
+ normalized = _normalize_cortex_tokens(value)
766
+ if not normalized:
767
+ return False
768
+ return any(token in normalized for token in tokens)
769
+
770
+
771
+ def _cortex_choice_is_negative(value: str) -> bool:
772
+ return _text_has_any_token(value, _CORTEX_NEGATIVE_TOKENS)
773
+
774
+
775
+ def _find_recent_cortex_authorization(
776
+ conn,
777
+ *,
778
+ sid: str,
779
+ task: dict | None,
780
+ gate_family: str,
781
+ pattern_name: str = "",
782
+ ) -> dict | None:
783
+ if not sid or not task:
784
+ return None
785
+ task_id = str(task.get("task_id") or "").strip()
786
+ if not task_id:
787
+ return None
788
+ params: list[object] = [
789
+ sid,
790
+ task_id,
791
+ f"-{_G3_CORTEX_AUTH_WINDOW_SECONDS} seconds",
792
+ ]
793
+ sql = (
794
+ """SELECT id, task_id, recommended_choice, selected_choice,
795
+ recommended_reasoning, selection_reason, context_hint, created_at
796
+ FROM cortex_evaluations
797
+ WHERE session_id = ?
798
+ AND task_id = ?
799
+ AND created_at >= datetime('now', ?)"""
800
+ )
801
+ opened_at = str(task.get("opened_at") or "").strip()
802
+ if opened_at:
803
+ sql += " AND created_at >= ?"
804
+ params.append(opened_at)
805
+ sql += " ORDER BY created_at DESC, id DESC LIMIT 5"
806
+ try:
807
+ rows = conn.execute(sql, params).fetchall()
808
+ except sqlite3.OperationalError:
809
+ return None
810
+ family_tokens = _G3_CORTEX_FAMILY_TOKENS.get(gate_family, ())
811
+ pattern_tokens = tuple(
812
+ token for token in _normalize_cortex_tokens(pattern_name).split("_") if token
813
+ )
814
+ fallback_candidates: list[dict] = []
815
+ for row in rows:
816
+ item = dict(row)
817
+ choice = str(item.get("selected_choice") or item.get("recommended_choice") or "").strip()
818
+ if not choice or _cortex_choice_is_negative(choice):
819
+ continue
820
+ combined = " ".join(
821
+ [
822
+ choice,
823
+ str(item.get("selection_reason") or ""),
824
+ str(item.get("recommended_reasoning") or ""),
825
+ str(item.get("context_hint") or ""),
826
+ ]
827
+ )
828
+ if (
829
+ _text_has_any_token(choice, _G3_CORTEX_GENERIC_APPROVAL_TOKENS)
830
+ or _text_has_any_token(combined, family_tokens)
831
+ or _text_has_any_token(combined, pattern_tokens)
832
+ ):
833
+ return item
834
+ fallback_candidates.append(item)
835
+ if len(fallback_candidates) == 1:
836
+ return fallback_candidates[0]
837
+ return None
838
+
839
+
688
840
  def _find_any_open_workflow(conn, sid: str) -> dict | None:
689
841
  row = conn.execute(
690
842
  """SELECT run_id, protocol_task_id, current_step_key
@@ -1164,6 +1316,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1164
1316
  if not claude_sid:
1165
1317
  claude_sid = _read_claude_session_id_from_coordination()
1166
1318
  sid = _resolve_nexo_sid(conn, claude_sid)
1319
+ open_task = _find_any_open_task(conn, sid) if sid else None
1167
1320
  automation_blocks = _collect_automation_live_repo_blocks(
1168
1321
  conn,
1169
1322
  sid=sid,
@@ -1228,12 +1381,22 @@ def process_pre_tool_event(payload: dict) -> dict:
1228
1381
  if g3_mode in {"shadow", "hard"} and tool_name == "Bash":
1229
1382
  shell_command = _extract_bash_command(tool_input)
1230
1383
  destructive_pattern = _classify_destructive_intent(shell_command)
1384
+ if destructive_pattern:
1385
+ if _find_recent_cortex_authorization(
1386
+ conn,
1387
+ sid=sid,
1388
+ task=open_task,
1389
+ gate_family="destructive",
1390
+ pattern_name=destructive_pattern,
1391
+ ):
1392
+ destructive_pattern = None
1231
1393
  if destructive_pattern:
1232
1394
  severity = "error" if g3_mode == "hard" else "warn"
1395
+ task_id = str((open_task or {}).get("task_id") or "").strip()
1233
1396
  debt = _ensure_protocol_debt(
1234
1397
  conn,
1235
1398
  session_id=sid,
1236
- task_id="",
1399
+ task_id=task_id,
1237
1400
  debt_type="g3_destructive_command_requires_cortex",
1238
1401
  severity=severity,
1239
1402
  evidence=(
@@ -1253,7 +1416,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1253
1416
  "blocks": [
1254
1417
  {
1255
1418
  "file": "",
1256
- "task_id": "",
1419
+ "task_id": task_id,
1257
1420
  "debt_id": debt.get("id"),
1258
1421
  "debt_type": "g3_destructive_command_requires_cortex",
1259
1422
  "reason_code": "g3_destructive_blocked",
@@ -1283,12 +1446,22 @@ def process_pre_tool_event(payload: dict) -> dict:
1283
1446
  if g3_ssh_mode in {"shadow", "hard"} and tool_name == "Bash":
1284
1447
  shell_command = _extract_bash_command(tool_input)
1285
1448
  ssh_pattern = _classify_ssh_remote_write(shell_command)
1449
+ if ssh_pattern:
1450
+ if _find_recent_cortex_authorization(
1451
+ conn,
1452
+ sid=sid,
1453
+ task=open_task,
1454
+ gate_family="ssh",
1455
+ pattern_name=ssh_pattern,
1456
+ ):
1457
+ ssh_pattern = None
1286
1458
  if ssh_pattern:
1287
1459
  severity = "error" if g3_ssh_mode == "hard" else "warn"
1460
+ task_id = str((open_task or {}).get("task_id") or "").strip()
1288
1461
  debt = _ensure_protocol_debt(
1289
1462
  conn,
1290
1463
  session_id=sid,
1291
- task_id="",
1464
+ task_id=task_id,
1292
1465
  debt_type="g3_ssh_remote_write_requires_cortex",
1293
1466
  severity=severity,
1294
1467
  evidence=(
@@ -1309,7 +1482,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1309
1482
  "blocks": [
1310
1483
  {
1311
1484
  "file": "",
1312
- "task_id": "",
1485
+ "task_id": task_id,
1313
1486
  "debt_id": debt.get("id"),
1314
1487
  "debt_type": "g3_ssh_remote_write_requires_cortex",
1315
1488
  "reason_code": "g3_ssh_remote_write_blocked",
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import hashlib
17
17
  import json
18
+ import logging
18
19
  import os
19
20
  import re
20
21
  import shutil
@@ -29,6 +30,7 @@ import paths
29
30
 
30
31
  MANIFEST_PATH = Path(__file__).resolve().with_name("local_model_manifest.json")
31
32
  MODEL_LOCK_FILENAME = ".nexo-model-lock.json"
33
+ logger = logging.getLogger(__name__)
32
34
 
33
35
 
34
36
  @dataclass(frozen=True)
@@ -79,6 +81,9 @@ def _lock_payload(spec: LocalModelSpec) -> dict[str, Any]:
79
81
 
80
82
  @lru_cache(maxsize=1)
81
83
  def _load_manifest() -> dict[str, LocalModelSpec]:
84
+ if not MANIFEST_PATH.exists():
85
+ logger.warning("local_model_manifest.json missing — running with empty manifest")
86
+ return {}
82
87
  payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
83
88
  specs: dict[str, LocalModelSpec] = {}
84
89
  for raw in payload.get("models", []) or []:
@@ -30,7 +30,11 @@ def main(argv: list[str] | None = None) -> int:
30
30
  parser.add_argument("--prompt", default="", help="Prompt text")
31
31
  parser.add_argument("--prompt-file", default="", help="Read prompt text from a file")
32
32
  parser.add_argument("--cwd", default="", help="Working directory for the backend")
33
- parser.add_argument("--task-profile", default="", help="Automation task profile: default|fast|balanced|deep")
33
+ parser.add_argument(
34
+ "--task-profile",
35
+ default="",
36
+ help="Legacy semantic label kept for compatibility. Routing now comes from caller/tier.",
37
+ )
34
38
  parser.add_argument("--model", default="", help="Backend model hint")
35
39
  parser.add_argument("--reasoning-effort", default="", help="Backend reasoning effort/profile")
36
40
  parser.add_argument(
@@ -50,6 +54,12 @@ def main(argv: list[str] | None = None) -> int:
50
54
  parser.add_argument("--timeout", type=int, default=AUTOMATION_SUBPROCESS_TIMEOUT, help="Timeout in seconds")
51
55
  parser.add_argument("--output-format", default="text", help="Requested output format")
52
56
  parser.add_argument("--allowed-tools", default="", help="Claude-style allowed tools contract")
57
+ parser.add_argument(
58
+ "--bare-mode",
59
+ choices=("auto", "on", "off"),
60
+ default="auto",
61
+ help="Bare mode for one-shot runs: auto|on|off.",
62
+ )
53
63
  parser.add_argument("--append-system-prompt", default="", help="Extra system prompt text")
54
64
  parser.add_argument("--append-system-prompt-file", default="", help="Read extra system prompt from a file")
55
65
  args = parser.parse_args(argv)
@@ -62,6 +72,11 @@ def main(argv: list[str] | None = None) -> int:
62
72
  return 1
63
73
 
64
74
  append_system_prompt = args.append_system_prompt or _read_text(args.append_system_prompt_file)
75
+ bare_mode = None
76
+ if args.bare_mode == "on":
77
+ bare_mode = True
78
+ elif args.bare_mode == "off":
79
+ bare_mode = False
65
80
 
66
81
  try:
67
82
  result = run_automation_prompt(
@@ -76,6 +91,7 @@ def main(argv: list[str] | None = None) -> int:
76
91
  output_format=args.output_format,
77
92
  append_system_prompt=append_system_prompt,
78
93
  allowed_tools=args.allowed_tools,
94
+ bare_mode=bare_mode,
79
95
  )
80
96
  except AutomationBackendUnavailableError as exc:
81
97
  print(str(exc), file=sys.stderr)
@@ -106,7 +106,6 @@ def main(argv: list[str]) -> int:
106
106
  "retry_backoff_seconds": legacy.get("retry_backoff_seconds", 60),
107
107
  "claude_binary": legacy.get("claude_binary", ""),
108
108
  "working_dir": legacy.get("working_dir", str(Path.home())),
109
- "automation_task_profile": legacy.get("automation_task_profile", "deep"),
110
109
  "max_process_time": legacy.get("max_process_time"),
111
110
  "sent_folder": legacy.get("sent_folder", "INBOX.Sent"),
112
111
  "operator_aliases": legacy_operator_aliases,
@@ -57,8 +57,6 @@ if str(NEXO_CODE) not in sys.path:
57
57
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
58
58
  from client_preferences import (
59
59
  resolve_automation_backend,
60
- resolve_automation_task_profile,
61
- resolve_client_runtime_profile,
62
60
  )
63
61
  from core_prompts import render_core_prompt
64
62
 
@@ -85,7 +83,6 @@ ZOMBIE_TIMEOUT_HOURS = 2
85
83
  MAX_AUTOMATION_TIMEOUT_SECONDS = 1800
86
84
  STALE_EMAIL_SESSION_MINUTES = 45
87
85
  WORKER_STALE_MAX_MINUTES = 120
88
- DEFAULT_AUTOMATION_TASK_PROFILE = "fast"
89
86
  CONCURRENT_THRESHOLD_MINUTES = 15
90
87
  MAX_CONCURRENT_SESSIONS = 2
91
88
  MAX_EMAIL_ATTEMPTS = 3
@@ -386,7 +383,10 @@ def load_config():
386
383
  except Exception:
387
384
  pass
388
385
  with open(CONFIG_PATH) as f:
389
- return json.load(f)
386
+ payload = json.load(f)
387
+ if isinstance(payload, dict):
388
+ payload.pop("automation_task_profile", None)
389
+ return payload
390
390
 
391
391
 
392
392
  def _safe_int(value, default=0):
@@ -1636,7 +1636,7 @@ def _reset_batch_to_pending(batch, reason):
1636
1636
  log.warning(f"Failed to reset claimed batch to pending: {exc}")
1637
1637
 
1638
1638
 
1639
- def _write_worker_job(*, batch, debt_block, max_retries, retry_backoff, task_profile):
1639
+ def _write_worker_job(*, batch, debt_block, max_retries, retry_backoff):
1640
1640
  job_id = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
1641
1641
  job_path = WORKER_JOBS_DIR / f"job-{job_id}.json"
1642
1642
  payload = {
@@ -1645,7 +1645,6 @@ def _write_worker_job(*, batch, debt_block, max_retries, retry_backoff, task_pro
1645
1645
  "debt_block": debt_block or "",
1646
1646
  "max_retries": int(max_retries),
1647
1647
  "retry_backoff_seconds": int(retry_backoff),
1648
- "task_profile": str(task_profile or DEFAULT_AUTOMATION_TASK_PROFILE),
1649
1648
  "created_at": datetime.now().isoformat(timespec="seconds"),
1650
1649
  }
1651
1650
  job_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True))
@@ -1689,16 +1688,12 @@ def _run_worker_job(job_path):
1689
1688
  debt_block = payload.get("debt_block") or ""
1690
1689
  max_retries = max(1, int(payload.get("max_retries") or 1))
1691
1690
  retry_backoff = max(1, int(payload.get("retry_backoff_seconds") or 60))
1692
- task_profile = str(payload.get("task_profile") or DEFAULT_AUTOMATION_TASK_PROFILE).strip().lower()
1693
1691
  except Exception as exc:
1694
1692
  log.error(f"Failed to load worker job {job_file}: {exc}")
1695
1693
  job_file.unlink(missing_ok=True)
1696
1694
  return 1
1697
1695
 
1698
1696
  config = load_config()
1699
- if task_profile:
1700
- config = dict(config)
1701
- config["automation_task_profile"] = task_profile
1702
1697
 
1703
1698
  log.info(
1704
1699
  f"Worker job started: {job_file.name} "
@@ -1998,20 +1993,17 @@ def launch_nexo(config, debt_block="", target_emails=None):
1998
1993
  env["HOME"] = str(Path.home())
1999
1994
 
2000
1995
  backend = resolve_automation_backend()
2001
- task_profile = str(config.get("automation_task_profile") or DEFAULT_AUTOMATION_TASK_PROFILE).strip().lower()
2002
- selected_profile = (
2003
- resolve_automation_task_profile(task_profile)
2004
- if task_profile
2005
- else {"name": "default", "backend": backend, "model": "", "reasoning_effort": ""}
2006
- )
2007
- launch_backend = selected_profile.get("backend") or backend
2008
- profile_label = selected_profile.get("model") or "default"
2009
- if selected_profile.get("reasoning_effort"):
2010
- profile_label = f"{profile_label}/{selected_profile['reasoning_effort']}"
2011
- log.info(
2012
- f"Launching NEXO via {launch_backend}"
2013
- f"{f' [{task_profile}]' if task_profile else ''} ({profile_label})..."
2014
- )
1996
+ profile_label = "default"
1997
+ try:
1998
+ from resonance_map import resolve_model_and_effort
1999
+
2000
+ mapped_model, mapped_effort = resolve_model_and_effort("email_monitor", backend)
2001
+ profile_label = mapped_model or "default"
2002
+ if mapped_effort:
2003
+ profile_label = f"{profile_label}/{mapped_effort}"
2004
+ except Exception:
2005
+ pass
2006
+ log.info(f"Launching NEXO via {backend} ({profile_label})...")
2015
2007
  requested_timeout = int(config.get("max_process_time", MAX_AUTOMATION_TIMEOUT_SECONDS) or MAX_AUTOMATION_TIMEOUT_SECONDS)
2016
2008
  effective_timeout = max(60, min(requested_timeout, MAX_AUTOMATION_TIMEOUT_SECONDS))
2017
2009
 
@@ -2042,7 +2034,6 @@ def launch_nexo(config, debt_block="", target_emails=None):
2042
2034
  result = run_automation_prompt(
2043
2035
  prompt,
2044
2036
  caller="email_monitor",
2045
- task_profile=task_profile,
2046
2037
  cwd=config.get("working_dir", str(Path.home())),
2047
2038
  env=env,
2048
2039
  timeout=effective_timeout,
@@ -2343,13 +2334,11 @@ def main():
2343
2334
 
2344
2335
  max_retries = config.get("max_retries", 3)
2345
2336
  retry_backoff = config.get("retry_backoff_seconds", 60)
2346
- task_profile = str(config.get("automation_task_profile") or DEFAULT_AUTOMATION_TASK_PROFILE).strip().lower()
2347
2337
  job_path = _write_worker_job(
2348
2338
  batch=batch,
2349
2339
  debt_block=debt_block,
2350
2340
  max_retries=max_retries,
2351
2341
  retry_backoff=retry_backoff,
2352
- task_profile=task_profile,
2353
2342
  )
2354
2343
  try:
2355
2344
  worker_pid = _spawn_worker(job_path, config)
@@ -17,8 +17,11 @@ Both paths are probed so dev and live operators get identical behaviour.
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
+ import inspect
20
21
  import os
22
+ import re
21
23
  import sys
24
+ import time
22
25
  from pathlib import Path
23
26
 
24
27
 
@@ -31,6 +34,12 @@ if str(_repo_src) not in sys.path:
31
34
 
32
35
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
33
36
  DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
37
+ DEFAULT_SHORT_TEXT_ALLOWED_TOOLS = ""
38
+ DEFAULT_SHORT_TEXT_TIMEOUT = max(
39
+ 30,
40
+ int(os.environ.get("NEXO_PERSONAL_AUTOMATION_TIMEOUT", "180")),
41
+ )
42
+ _PROCESS_LOCK_COUNTS: dict[str, int] = {}
34
43
 
35
44
  # Templates live next to the code at repo time and at ``~/.nexo/templates``
36
45
  # once installed. Probe both and surface whichever exists first so the
@@ -41,45 +50,161 @@ for _candidate in (_repo_root / "templates", NEXO_HOME / "templates"):
41
50
  if _candidate.exists() and _cand not in sys.path:
42
51
  sys.path.insert(0, _cand)
43
52
 
44
- try:
45
- from client_preferences import resolve_user_model
46
- _USER_MODEL = resolve_user_model()
47
- except Exception:
48
- _USER_MODEL = ""
49
-
50
53
  from nexo_helper import run_automation_text as _run_automation_text
51
54
 
52
55
 
56
+ def _infer_personal_caller() -> str:
57
+ env_caller = str(os.environ.get("NEXO_AUTOMATION_CALLER") or "").strip()
58
+ if env_caller:
59
+ return env_caller
60
+ candidates: list[Path] = []
61
+ argv0 = str(sys.argv[0] or "").strip()
62
+ if argv0:
63
+ candidates.append(Path(argv0).expanduser())
64
+ current = Path(__file__).resolve()
65
+ for frame in inspect.stack()[1:]:
66
+ try:
67
+ path = Path(frame.filename).resolve()
68
+ except Exception:
69
+ continue
70
+ if path != current:
71
+ candidates.append(path)
72
+ for candidate in candidates:
73
+ parts = candidate.parts
74
+ if "personal" in parts and "scripts" in parts:
75
+ stem = candidate.stem.strip()
76
+ if stem:
77
+ return f"personal/{stem}"
78
+ if argv0:
79
+ stem = Path(argv0).stem.strip()
80
+ if stem and stem not in {"python", "python3", "-m"}:
81
+ return f"personal/{stem}"
82
+ return "agent_run/generic"
83
+
84
+
85
+ def _caller_lock_path(caller: str) -> Path:
86
+ slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", caller).strip("-") or "generic"
87
+ return NEXO_HOME / "runtime" / "locks" / "personal-automation" / f"{slug}.lock"
88
+
89
+
90
+ def _read_lock_pid(path: Path) -> int:
91
+ try:
92
+ raw = path.read_text().splitlines()
93
+ except Exception:
94
+ return 0
95
+ if not raw:
96
+ return 0
97
+ try:
98
+ return int(raw[0].strip())
99
+ except Exception:
100
+ return 0
101
+
102
+
103
+ def _pid_is_alive(pid: int) -> bool:
104
+ if pid <= 0:
105
+ return False
106
+ try:
107
+ os.kill(pid, 0)
108
+ except ProcessLookupError:
109
+ return False
110
+ except PermissionError:
111
+ return True
112
+ return True
113
+
114
+
115
+ def _acquire_personal_caller_lock(caller: str) -> str:
116
+ clean = str(caller or "").strip()
117
+ if not clean.startswith("personal/"):
118
+ return ""
119
+ if _PROCESS_LOCK_COUNTS.get(clean, 0) > 0:
120
+ _PROCESS_LOCK_COUNTS[clean] += 1
121
+ return clean
122
+ pid = os.getpid()
123
+ lock_path = _caller_lock_path(clean)
124
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
125
+ while True:
126
+ try:
127
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
128
+ except FileExistsError:
129
+ existing_pid = _read_lock_pid(lock_path)
130
+ if existing_pid == pid:
131
+ _PROCESS_LOCK_COUNTS[clean] = 1
132
+ return clean
133
+ if existing_pid and _pid_is_alive(existing_pid):
134
+ raise RuntimeError(
135
+ f"Automation caller {clean} already has a live run (pid {existing_pid})."
136
+ )
137
+ try:
138
+ lock_path.unlink()
139
+ except FileNotFoundError:
140
+ pass
141
+ continue
142
+ with os.fdopen(fd, "w", encoding="ascii") as handle:
143
+ handle.write(f"{pid}\n{int(time.time())}\n{clean}\n")
144
+ _PROCESS_LOCK_COUNTS[clean] = 1
145
+ return clean
146
+
147
+
148
+ def _release_personal_caller_lock(caller: str) -> None:
149
+ clean = str(caller or "").strip()
150
+ if not clean.startswith("personal/"):
151
+ return
152
+ count = _PROCESS_LOCK_COUNTS.get(clean, 0)
153
+ if count > 1:
154
+ _PROCESS_LOCK_COUNTS[clean] = count - 1
155
+ return
156
+ _PROCESS_LOCK_COUNTS.pop(clean, None)
157
+ lock_path = _caller_lock_path(clean)
158
+ if _read_lock_pid(lock_path) == os.getpid():
159
+ try:
160
+ lock_path.unlink()
161
+ except FileNotFoundError:
162
+ pass
163
+
164
+
53
165
  def run_personal_automation_text(
54
166
  prompt: str,
55
167
  *,
56
168
  model: str = "",
57
169
  cwd: str = "",
58
- timeout: int = 21600,
59
- allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
170
+ timeout: int = DEFAULT_SHORT_TEXT_TIMEOUT,
171
+ allowed_tools: str = DEFAULT_SHORT_TEXT_ALLOWED_TOOLS,
60
172
  append_system_prompt: str = "",
173
+ caller: str = "",
174
+ tier: str = "",
175
+ bare_mode: bool | None = True,
61
176
  ) -> str:
62
177
  """Run ``prompt`` through the configured NEXO automation backend.
63
178
 
64
- ``model`` empty use whichever model the operator's calibration has
65
- selected (``resolve_user_model``); providers that ignore the field
66
- (Claude Code bundled) stay happy with an empty string.
179
+ ``model`` stays empty unless the caller provides an explicit override.
180
+ Backend/model/effort resolution belongs to the resonance engine via
181
+ ``caller`` and ``tier``.
67
182
  ``cwd`` empty → inherit the current working directory.
68
183
  Every other kwarg passes through verbatim.
69
184
  """
70
- effective_model = model or _USER_MODEL or "opus"
71
- return _run_automation_text(
72
- prompt,
73
- model=effective_model,
74
- cwd=cwd or "",
75
- timeout=timeout,
76
- allowed_tools=allowed_tools,
77
- append_system_prompt=append_system_prompt,
78
- )
185
+ effective_caller = caller or _infer_personal_caller()
186
+ lock_token = _acquire_personal_caller_lock(effective_caller)
187
+ try:
188
+ return _run_automation_text(
189
+ prompt,
190
+ model=model,
191
+ cwd=cwd or "",
192
+ timeout=timeout,
193
+ allowed_tools=allowed_tools,
194
+ append_system_prompt=append_system_prompt,
195
+ include_bootstrap=False,
196
+ caller=effective_caller,
197
+ tier=tier,
198
+ bare_mode=bare_mode,
199
+ )
200
+ finally:
201
+ _release_personal_caller_lock(lock_token)
79
202
 
80
203
 
81
204
  __all__ = [
82
205
  "DEFAULT_ALLOWED_TOOLS",
206
+ "DEFAULT_SHORT_TEXT_ALLOWED_TOOLS",
207
+ "DEFAULT_SHORT_TEXT_TIMEOUT",
83
208
  "NEXO_HOME",
84
209
  "run_personal_automation_text",
85
210
  ]
@@ -20,6 +20,7 @@ import paths
20
20
  from doctor.formatters import format_report
21
21
  from doctor.orchestrator import run_doctor
22
22
  from health_check import collect as collect_health
23
+ from windows_runtime import running_inside_wsl, windows_runtime_status
23
24
 
24
25
 
25
26
  def _nexo_home() -> Path:
@@ -111,15 +112,19 @@ def _recent_logs(lines: int = 80) -> dict[str, Any]:
111
112
 
112
113
 
113
114
  def collect_snapshot(*, log_lines: int = 80, include_doctor: bool = False) -> dict[str, Any]:
115
+ system = platform.system()
116
+ release = platform.release()
114
117
  payload: dict[str, Any] = {
115
118
  "generated_at": time.time(),
116
119
  "version": _read_version(),
117
120
  "platform": {
118
- "system": platform.system(),
119
- "release": platform.release(),
121
+ "system": system,
122
+ "release": release,
120
123
  "machine": platform.machine(),
121
124
  "python": platform.python_version(),
125
+ "is_wsl": running_inside_wsl(system=system, release=release),
122
126
  },
127
+ "windows_runtime": windows_runtime_status(_nexo_home(), system=system, release=release),
123
128
  "paths": _path_status(),
124
129
  "health": collect_health(),
125
130
  "logs": _recent_logs(log_lines),
@@ -0,0 +1,70 @@
1
+ """Helpers for Windows/WSL runtime diagnostics."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import platform
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def running_inside_wsl(*, system: str | None = None, release: str | None = None) -> bool:
11
+ resolved_system = str(system or platform.system() or "").strip()
12
+ resolved_release = str(release or platform.release() or "").strip().lower()
13
+ if resolved_system != "Linux":
14
+ return False
15
+ if "microsoft" in resolved_release:
16
+ return True
17
+ if str(os.environ.get("WSL_DISTRO_NAME", "")).strip():
18
+ return True
19
+ if str(os.environ.get("WSL_INTEROP", "")).strip():
20
+ return True
21
+ return False
22
+
23
+
24
+ def running_from_windows_host() -> bool:
25
+ value = str(os.environ.get("NEXO_WINDOWS_HOST", "")).strip().lower()
26
+ return value not in ("", "0", "false", "no", "off")
27
+
28
+
29
+ def bridge_mode() -> str:
30
+ value = str(os.environ.get("NEXO_WINDOWS_BRIDGE", "")).strip()
31
+ return "wsl-exec" if value else ""
32
+
33
+
34
+ def is_windows_mount_path(candidate: Path) -> bool:
35
+ normalized = str(candidate or "").replace("\\", "/").lower()
36
+ return normalized.startswith("/mnt/")
37
+
38
+
39
+ def windows_runtime_status(nexo_home: Path, *, system: str | None = None, release: str | None = None) -> dict[str, Any]:
40
+ resolved_system = str(system or platform.system() or "").strip()
41
+ resolved_release = str(release or platform.release() or "").strip()
42
+ inside_wsl = running_inside_wsl(system=resolved_system, release=resolved_release)
43
+ on_windows_mount = is_windows_mount_path(nexo_home)
44
+ warnings: list[dict[str, str]] = []
45
+
46
+ if resolved_system == "Windows":
47
+ warnings.append(
48
+ {
49
+ "code": "brain_requires_wsl",
50
+ "message": "NEXO Brain on Windows is supported via WSL, not native win32 mode.",
51
+ }
52
+ )
53
+ if on_windows_mount:
54
+ warnings.append(
55
+ {
56
+ "code": "nexo_home_on_windows_mount",
57
+ "message": "NEXO_HOME is inside /mnt/*; keep the canonical Brain runtime inside the WSL filesystem.",
58
+ }
59
+ )
60
+
61
+ return {
62
+ "supported_brain_mode": "wsl",
63
+ "inside_wsl": inside_wsl,
64
+ "windows_host_bridge": running_from_windows_host(),
65
+ "bridge_mode": bridge_mode(),
66
+ "wsl_distro": str(os.environ.get("WSL_DISTRO_NAME", "")).strip(),
67
+ "wsl_interop": bool(str(os.environ.get("WSL_INTEROP", "")).strip()),
68
+ "nexo_home_on_windows_mount": on_windows_mount,
69
+ "warnings": warnings,
70
+ }
@@ -226,6 +226,7 @@ def run_automation_text(
226
226
  include_bootstrap: bool = True,
227
227
  caller: str = "",
228
228
  tier: str = "",
229
+ bare_mode: bool | None = None,
229
230
  ) -> str:
230
231
  """Run the configured NEXO automation backend and return text output.
231
232
 
@@ -264,6 +265,10 @@ def run_automation_text(
264
265
  cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
265
266
  if allowed_tools:
266
267
  cmd.extend(["--allowed-tools", allowed_tools])
268
+ if bare_mode is True:
269
+ cmd.extend(["--bare-mode", "on"])
270
+ elif bare_mode is False:
271
+ cmd.extend(["--bare-mode", "off"])
267
272
 
268
273
  env = os.environ.copy()
269
274
  env.setdefault("NEXO_HOME", str(NEXO_HOME))
@@ -292,6 +297,7 @@ def run_automation_json(
292
297
  include_bootstrap: bool = True,
293
298
  caller: str = "",
294
299
  tier: str = "",
300
+ bare_mode: bool | None = None,
295
301
  ) -> dict:
296
302
  """Run the configured backend and return a parsed JSON object.
297
303
 
@@ -326,6 +332,10 @@ def run_automation_json(
326
332
  cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
327
333
  if allowed_tools:
328
334
  cmd.extend(["--allowed-tools", allowed_tools])
335
+ if bare_mode is True:
336
+ cmd.extend(["--bare-mode", "on"])
337
+ elif bare_mode is False:
338
+ cmd.extend(["--bare-mode", "off"])
329
339
 
330
340
  env = os.environ.copy()
331
341
  env.setdefault("NEXO_HOME", str(NEXO_HOME))