nexo-brain 5.3.0 → 5.3.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.
@@ -18,6 +18,7 @@ except ModuleNotFoundError: # Python < 3.11
18
18
  import tomli as tomllib
19
19
 
20
20
  from bootstrap_docs import sync_client_bootstrap
21
+ from runtime_home import resolve_nexo_home
21
22
 
22
23
  try:
23
24
  from client_preferences import (
@@ -73,7 +74,7 @@ def _user_home() -> Path:
73
74
 
74
75
 
75
76
  def _default_nexo_home() -> Path:
76
- return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
77
+ return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
77
78
 
78
79
 
79
80
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
@@ -348,6 +349,12 @@ CORE_HOOK_SPECS = [
348
349
  "timeout": 5,
349
350
  "script": "protocol-pretool-guardrail.sh",
350
351
  },
352
+ {
353
+ "event": "UserPromptSubmit",
354
+ "identity": "heartbeat-user-msg.sh",
355
+ "timeout": 3,
356
+ "script": "heartbeat-user-msg.sh",
357
+ },
351
358
  {
352
359
  "event": "PostToolUse",
353
360
  "identity": "capture-tool-logs.sh",
@@ -372,6 +379,12 @@ CORE_HOOK_SPECS = [
372
379
  "timeout": 5,
373
380
  "script": "protocol-guardrail.sh",
374
381
  },
382
+ {
383
+ "event": "PostToolUse",
384
+ "identity": "heartbeat-posttool.sh",
385
+ "timeout": 3,
386
+ "script": "heartbeat-posttool.sh",
387
+ },
375
388
  {
376
389
  "event": "PreCompact",
377
390
  "identity": "pre-compact.sh",
@@ -12,15 +12,30 @@ import os
12
12
  from pathlib import Path
13
13
 
14
14
  from db._core import get_db
15
+ from runtime_home import resolve_nexo_home
15
16
 
16
17
 
17
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
+ NEXO_HOME = resolve_nexo_home()
18
19
 
19
20
 
20
21
  def _now_text() -> str:
21
22
  return datetime.datetime.now().isoformat(timespec="seconds")
22
23
 
23
24
 
25
+ def _canonical_scripts_dir() -> Path:
26
+ home = resolve_nexo_home(os.environ.get("NEXO_HOME", str(NEXO_HOME)))
27
+ return home / "scripts"
28
+
29
+
30
+ def _normalize_script_path(path: str | Path) -> str:
31
+ candidate = Path(path).expanduser()
32
+ try:
33
+ relative = candidate.resolve(strict=False).relative_to(_canonical_scripts_dir().resolve(strict=False))
34
+ except Exception:
35
+ return str(candidate)
36
+ return str(_canonical_scripts_dir() / relative)
37
+
38
+
24
39
  def _row_to_dict(row) -> dict:
25
40
  return dict(row) if row is not None else {}
26
41
 
@@ -61,6 +76,7 @@ def _safe_slug(value: str) -> str:
61
76
 
62
77
 
63
78
  def _ensure_script_id(conn, name: str, path: str) -> str:
79
+ path = _normalize_script_path(path)
64
80
  existing = conn.execute(
65
81
  "SELECT id FROM personal_scripts WHERE path = ? LIMIT 1",
66
82
  (path,),
@@ -90,6 +106,7 @@ def upsert_personal_script(
90
106
  has_inline_metadata: bool = False,
91
107
  ) -> dict:
92
108
  conn = get_db()
109
+ path = _normalize_script_path(path)
93
110
  script_id = _ensure_script_id(conn, name, path)
94
111
  now = _now_text()
95
112
  conn.execute(
@@ -132,11 +149,12 @@ def upsert_personal_script(
132
149
 
133
150
  def delete_missing_personal_scripts(active_paths: list[str]) -> int:
134
151
  conn = get_db()
135
- if active_paths:
136
- placeholders = ",".join("?" for _ in active_paths)
152
+ normalized_paths = [_normalize_script_path(path) for path in active_paths]
153
+ if normalized_paths:
154
+ placeholders = ",".join("?" for _ in normalized_paths)
137
155
  rows = conn.execute(
138
156
  f"SELECT id FROM personal_scripts WHERE path NOT IN ({placeholders})",
139
- tuple(active_paths),
157
+ tuple(normalized_paths),
140
158
  ).fetchall()
141
159
  else:
142
160
  rows = conn.execute("SELECT id FROM personal_scripts").fetchall()
@@ -160,6 +178,7 @@ def register_personal_script_schedule(
160
178
  enabled: bool = True,
161
179
  ) -> dict | None:
162
180
  conn = get_db()
181
+ script_path = _normalize_script_path(script_path)
163
182
  script = conn.execute(
164
183
  "SELECT id FROM personal_scripts WHERE path = ?",
165
184
  (script_path,),
@@ -342,6 +361,7 @@ def list_personal_scripts(include_disabled: bool = True) -> list[dict]:
342
361
 
343
362
  def get_personal_script(name_or_path: str) -> dict | None:
344
363
  conn = get_db()
364
+ normalized_path = _normalize_script_path(name_or_path)
345
365
  row = conn.execute(
346
366
  """
347
367
  SELECT * FROM personal_scripts
@@ -349,7 +369,7 @@ def get_personal_script(name_or_path: str) -> dict | None:
349
369
  ORDER BY path = ? DESC
350
370
  LIMIT 1
351
371
  """,
352
- (name_or_path, name_or_path, name_or_path),
372
+ (normalized_path, name_or_path, normalized_path),
353
373
  ).fetchone()
354
374
  if not row:
355
375
  return None
@@ -361,9 +381,10 @@ def get_personal_script(name_or_path: str) -> dict | None:
361
381
 
362
382
  def delete_personal_script(name_or_path: str) -> int:
363
383
  conn = get_db()
384
+ normalized_path = _normalize_script_path(name_or_path)
364
385
  result = conn.execute(
365
386
  "DELETE FROM personal_scripts WHERE path = ? OR name = ? OR id = ?",
366
- (name_or_path, name_or_path, name_or_path),
387
+ (normalized_path, name_or_path, name_or_path),
367
388
  )
368
389
  return int(result.rowcount or 0)
369
390
 
@@ -371,13 +392,14 @@ def delete_personal_script(name_or_path: str) -> int:
371
392
  def record_personal_script_run(name_or_path: str, exit_code: int, run_at: str | None = None) -> None:
372
393
  conn = get_db()
373
394
  run_at = run_at or _now_text()
395
+ normalized_path = _normalize_script_path(name_or_path)
374
396
  conn.execute(
375
397
  """
376
398
  UPDATE personal_scripts
377
399
  SET last_run_at = ?, last_exit_code = ?, updated_at = ?
378
400
  WHERE path = ? OR name = ?
379
401
  """,
380
- (run_at, exit_code, _now_text(), name_or_path, name_or_path),
402
+ (run_at, exit_code, _now_text(), normalized_path, name_or_path),
381
403
  )
382
404
 
383
405
 
@@ -393,7 +415,7 @@ def sync_personal_scripts_registry(
393
415
  scheduled = 0
394
416
 
395
417
  for record in script_records:
396
- path = str(record["path"])
418
+ path = _normalize_script_path(record["path"])
397
419
  active_paths.append(path)
398
420
  upsert_personal_script(
399
421
  name=record.get("name") or Path(path).stem,
@@ -84,6 +84,28 @@ def _release_root() -> Path:
84
84
  return Path(NEXO_CODE)
85
85
 
86
86
 
87
+ def _has_release_publish_context(release_root: Path) -> bool:
88
+ git_dir = release_root / ".git"
89
+ if git_dir.exists() or git_dir.is_file():
90
+ return True
91
+ if _recorded_source_root() is not None:
92
+ return True
93
+ try:
94
+ release_root_resolved = release_root.resolve()
95
+ nexo_home_resolved = NEXO_HOME.resolve()
96
+ except Exception:
97
+ release_root_resolved = release_root
98
+ nexo_home_resolved = NEXO_HOME
99
+ if release_root_resolved == nexo_home_resolved:
100
+ return False
101
+ has_release_files = (
102
+ (release_root / "package.json").is_file()
103
+ and (release_root / "CHANGELOG.md").is_file()
104
+ and (release_root / "scripts" / "sync_release_artifacts.py").is_file()
105
+ )
106
+ return has_release_files
107
+
108
+
87
109
  def _package_json_path() -> Path:
88
110
  if PACKAGE_JSON.is_file():
89
111
  return PACKAGE_JSON
@@ -2660,13 +2682,27 @@ def check_release_artifact_sync() -> DoctorCheck:
2660
2682
  if changelog_version:
2661
2683
  evidence.append(f"top changelog version: {changelog_version}")
2662
2684
 
2685
+ release_root = _release_root()
2686
+ if not _has_release_publish_context(release_root):
2687
+ if version:
2688
+ evidence.append(f"package version: {version}")
2689
+ evidence.append(f"release root: {release_root}")
2690
+ evidence.append("packaged runtime without source repo; release artifact audit skipped")
2691
+ return DoctorCheck(
2692
+ id="runtime.release_artifacts",
2693
+ tier="runtime",
2694
+ status="healthy",
2695
+ severity="info",
2696
+ summary="Release artifact audit skipped for packaged runtime",
2697
+ evidence=evidence,
2698
+ )
2699
+
2663
2700
  if version and changelog_version and version != changelog_version:
2664
2701
  status = "critical"
2665
2702
  severity = "error"
2666
2703
  evidence.append("package/changelog release version mismatch")
2667
2704
  repair_plan.append("Bump or align CHANGELOG.md before publishing")
2668
2705
 
2669
- release_root = _release_root()
2670
2706
  sync_script = release_root / "scripts" / "sync_release_artifacts.py"
2671
2707
  if not sync_script.is_file():
2672
2708
  status = "critical"
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Heartbeat enforcement for NEXO sessions.
3
+
4
+ Tracks user messages vs heartbeat calls. Emits a warning when more than two
5
+ user messages pass without a heartbeat call.
6
+
7
+ Modes:
8
+ - HEARTBEAT_MODE=user_msg: increment counter on UserPromptSubmit
9
+ - HEARTBEAT_MODE=post_tool: inspect PostToolUse payload, reset on heartbeat,
10
+ warn when other tools keep running without one
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ STATE_FILE = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations" / ".heartbeat-state.json"
22
+ THRESHOLD = 2
23
+ HEARTBEAT_TOOL = "nexo_heartbeat"
24
+ SKIP_TOOLS = {"nexo_startup", "nexo_stop", "nexo_smart_startup"}
25
+
26
+
27
+ def _read_state() -> dict:
28
+ try:
29
+ return json.loads(STATE_FILE.read_text())
30
+ except Exception:
31
+ return {"user_msgs": 0, "last_heartbeat_ts": 0.0, "last_user_msg_ts": 0.0}
32
+
33
+
34
+ def _write_state(state: dict) -> None:
35
+ try:
36
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
37
+ STATE_FILE.write_text(json.dumps(state))
38
+ except Exception:
39
+ pass
40
+
41
+
42
+ def handle_user_message() -> int:
43
+ state = _read_state()
44
+ state["user_msgs"] = state.get("user_msgs", 0) + 1
45
+ state["last_user_msg_ts"] = time.time()
46
+ _write_state(state)
47
+ return 0
48
+
49
+
50
+ def handle_post_tool(payload: dict) -> int:
51
+ tool_name = str(payload.get("tool_name", "")).strip()
52
+ short_name = tool_name.rsplit("__", 1)[-1] if "__" in tool_name else tool_name
53
+ state = _read_state()
54
+
55
+ if short_name == HEARTBEAT_TOOL:
56
+ state["user_msgs"] = 0
57
+ state["last_heartbeat_ts"] = time.time()
58
+ _write_state(state)
59
+ return 0
60
+
61
+ if short_name in SKIP_TOOLS:
62
+ return 0
63
+
64
+ user_msgs = state.get("user_msgs", 0)
65
+ if user_msgs > THRESHOLD:
66
+ print(
67
+ f"\nWARNING: HEARTBEAT OVERDUE ({user_msgs} user messages without nexo_heartbeat). "
68
+ "Call nexo_heartbeat(sid=SID, task='...') before continuing."
69
+ )
70
+ return 0
71
+
72
+
73
+ def main() -> int:
74
+ mode = os.environ.get("HEARTBEAT_MODE", "").strip()
75
+ if mode == "user_msg":
76
+ return handle_user_message()
77
+ if mode == "post_tool":
78
+ raw = sys.stdin.read()
79
+ if not raw.strip():
80
+ return 0
81
+ try:
82
+ payload = json.loads(raw)
83
+ except Exception:
84
+ return 0
85
+ return handle_post_tool(payload)
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # NEXO PostToolUse hook — heartbeat enforcement checker
3
+ set -uo pipefail
4
+
5
+ INPUT=$(cat || true)
6
+ [ -z "$INPUT" ] && exit 0
7
+
8
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
+ HELPER=""
10
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
11
+ HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
12
+ elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
13
+ HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
14
+ fi
15
+
16
+ [ -z "$HELPER" ] && exit 0
17
+ HEARTBEAT_MODE=post_tool python3 "$HELPER" <<< "$INPUT" 2>/dev/null || true
18
+ exit 0
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # NEXO UserPromptSubmit hook — track user messages for heartbeat enforcement
3
+ set -uo pipefail
4
+
5
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
6
+ HELPER=""
7
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
8
+ HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
9
+ elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
10
+ HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
11
+ fi
12
+
13
+ [ -z "$HELPER" ] && exit 0
14
+ HEARTBEAT_MODE=user_msg python3 "$HELPER" 2>/dev/null || true
15
+ exit 0
@@ -4,6 +4,8 @@
4
4
  # Caches output for 1 hour to avoid regenerating on rapid successive sessions.
5
5
  set -uo pipefail
6
6
 
7
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
8
+
7
9
  # Fase 3 item 7: hook lifecycle observability — record duration + exit code
8
10
  # in hook_runs on EXIT. Best-effort: a failure here must not break the hook.
9
11
  NEXO_HOOK_START_MS=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo 0)
@@ -18,8 +20,12 @@ _nexo_record_hook_run() {
18
20
  duration_ms=$((now_ms - NEXO_HOOK_START_MS))
19
21
  fi
20
22
  fi
21
- local recorder
22
- recorder="${NEXO_CODE:-/Users/franciscoc/Documents/_PhpstormProjects/nexo/src}/scripts/nexo-hook-record.py"
23
+ local recorder=""
24
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/scripts/nexo-hook-record.py" ]; then
25
+ recorder="${NEXO_CODE%/}/scripts/nexo-hook-record.py"
26
+ elif [ -f "$NEXO_HOME/scripts/nexo-hook-record.py" ]; then
27
+ recorder="$NEXO_HOME/scripts/nexo-hook-record.py"
28
+ fi
23
29
  if [ -f "$recorder" ]; then
24
30
  python3 "$recorder" record \
25
31
  --hook "$NEXO_HOOK_NAME" \
@@ -30,8 +36,6 @@ _nexo_record_hook_run() {
30
36
  fi
31
37
  }
32
38
  trap _nexo_record_hook_run EXIT
33
-
34
- NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
35
39
  BRIEFING_FILE="$NEXO_HOME/coordination/session-briefing.txt"
36
40
  MAX_AGE_SECONDS=3600 # 1 hour cache
37
41
 
@@ -9,6 +9,8 @@ import sys
9
9
  import time
10
10
  from pathlib import Path
11
11
 
12
+ from runtime_home import export_resolved_nexo_home
13
+
12
14
  # Code root is the parent of plugins/:
13
15
  # - source checkout: <repo>/src
14
16
  # - packaged runtime: <NEXO_HOME>
@@ -16,7 +18,7 @@ _THIS_DIR = Path(__file__).resolve().parent
16
18
  CODE_ROOT = _THIS_DIR.parent
17
19
  _REPO_CANDIDATE = CODE_ROOT.parent
18
20
 
19
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
21
+ NEXO_HOME = export_resolved_nexo_home()
20
22
  DATA_DIR = NEXO_HOME / "data"
21
23
  BACKUP_BASE = NEXO_HOME / "backups"
22
24
 
@@ -79,7 +81,7 @@ def _is_git_repo() -> bool:
79
81
 
80
82
 
81
83
  def _refresh_installed_manifest():
82
- """Copy source crons/ to NEXO_HOME/crons/ so catchup & watchdog stay current."""
84
+ """Refresh packaged crons and persist the runtime core-artifacts manifest."""
83
85
  try:
84
86
  src_crons = SRC_DIR / "crons"
85
87
  dst_crons = NEXO_HOME / "crons"
@@ -88,10 +90,45 @@ def _refresh_installed_manifest():
88
90
  for f in src_crons.iterdir():
89
91
  if f.is_file():
90
92
  shutil.copy2(str(f), str(dst_crons / f.name))
93
+ config_dir = NEXO_HOME / "config"
94
+ config_dir.mkdir(parents=True, exist_ok=True)
95
+ payload = {
96
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
97
+ "script_names": sorted(
98
+ f.name for f in (SRC_DIR / "scripts").iterdir()
99
+ if f.is_file()
100
+ ) if (SRC_DIR / "scripts").is_dir() else [],
101
+ "hook_names": sorted(
102
+ f.name for f in (SRC_DIR / "hooks").iterdir()
103
+ if f.is_file()
104
+ ) if (SRC_DIR / "hooks").is_dir() else [],
105
+ }
106
+ (config_dir / "runtime-core-artifacts.json").write_text(
107
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
108
+ )
91
109
  except Exception:
92
110
  pass
93
111
 
94
112
 
113
+ def _cleanup_retired_runtime_files() -> list[str]:
114
+ removed: list[str] = []
115
+ retired_paths = [
116
+ NEXO_HOME / "scripts" / "heartbeat-enforcement.py",
117
+ NEXO_HOME / "scripts" / "heartbeat-posttool.sh",
118
+ NEXO_HOME / "scripts" / "heartbeat-user-msg.sh",
119
+ NEXO_HOME / "hooks" / "heartbeat-guard.sh",
120
+ ]
121
+ for path in retired_paths:
122
+ if not path.exists():
123
+ continue
124
+ try:
125
+ path.unlink()
126
+ removed.append(str(path))
127
+ except Exception:
128
+ continue
129
+ return removed
130
+
131
+
95
132
  def _read_version() -> str:
96
133
  """Read the installed/runtime version."""
97
134
  if _PACKAGED_INSTALL:
@@ -330,8 +367,23 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
330
367
  timestamp = time.strftime("%Y-%m-%d-%H%M%S")
331
368
  backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
332
369
  # Directories and flat files that postinstall copies into NEXO_HOME
333
- code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts"]
334
- code_files_glob = ["*.py", "requirements.txt"]
370
+ code_dirs = [
371
+ "bin",
372
+ "hooks",
373
+ "plugins",
374
+ "db",
375
+ "cognitive",
376
+ "dashboard",
377
+ "rules",
378
+ "crons",
379
+ "scripts",
380
+ "doctor",
381
+ "skills",
382
+ "skills-core",
383
+ "skills-runtime",
384
+ "templates",
385
+ ]
386
+ code_files_glob = ["*.py", "requirements.txt", "package.json"]
335
387
  try:
336
388
  backup_dir.mkdir(parents=True, exist_ok=True)
337
389
  # Backup directories
@@ -372,6 +424,54 @@ def _restore_code_tree(backup_dir: str) -> str | None:
372
424
  return None
373
425
 
374
426
 
427
+ def _normalize_preferences_for_client_sync() -> dict:
428
+ from client_preferences import normalize_client_preferences
429
+
430
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
431
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
432
+ normalized_preferences = normalize_client_preferences(schedule_payload)
433
+ if normalized_preferences != {
434
+ key: schedule_payload.get(key)
435
+ for key in normalized_preferences
436
+ }:
437
+ merged_schedule = dict(schedule_payload)
438
+ merged_schedule.update(normalized_preferences)
439
+ schedule_path.parent.mkdir(parents=True, exist_ok=True)
440
+ schedule_path.write_text(json.dumps(merged_schedule, indent=2, ensure_ascii=False) + "\n")
441
+ return normalized_preferences
442
+
443
+
444
+ def _sync_packaged_clients() -> tuple[bool, str | None]:
445
+ try:
446
+ from client_sync import sync_all_clients
447
+ except Exception as e:
448
+ return False, f"client sync import failed: {e}"
449
+
450
+ try:
451
+ preferences = _normalize_preferences_for_client_sync()
452
+ result = sync_all_clients(
453
+ nexo_home=NEXO_HOME,
454
+ runtime_root=NEXO_HOME,
455
+ operator_name=os.environ.get("NEXO_NAME", ""),
456
+ preferences=preferences,
457
+ )
458
+ except Exception as e:
459
+ return False, f"client sync failed: {e}"
460
+
461
+ if result.get("ok"):
462
+ return True, None
463
+
464
+ clients = result.get("clients", {})
465
+ failures = []
466
+ for key, payload in clients.items():
467
+ if payload.get("ok") or payload.get("skipped"):
468
+ continue
469
+ failures.append(f"{key}: {payload.get('error', 'unknown error')}")
470
+ if not failures:
471
+ failures.append("unknown client sync failure")
472
+ return False, "; ".join(failures)
473
+
474
+
375
475
  def _rollback_npm_package(target_version: str) -> str | None:
376
476
  """Rollback nexo-brain npm package to a specific version.
377
477
 
@@ -480,6 +580,22 @@ def _handle_packaged_update(progress_fn=None) -> str:
480
580
  if verify_err:
481
581
  errors.append(f"verification: {verify_err}")
482
582
 
583
+ hook_sync_warning = None
584
+ retired_runtime_files: list[str] = []
585
+ try:
586
+ _emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
587
+ _refresh_installed_manifest()
588
+ _sync_hooks_to_home()
589
+ retired_runtime_files = _cleanup_retired_runtime_files()
590
+ except Exception as e:
591
+ hook_sync_warning = f"{e}"
592
+
593
+ client_sync_warning = None
594
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
595
+ clients_ok, client_sync_error = _sync_packaged_clients()
596
+ if not clients_ok:
597
+ client_sync_warning = client_sync_error or "unknown client sync error"
598
+
483
599
  if errors:
484
600
  # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
485
601
  if code_backup_dir:
@@ -516,6 +632,16 @@ def _handle_packaged_update(progress_fn=None) -> str:
516
632
  lines = ["UPDATE SUCCESSFUL (packaged install)"]
517
633
  lines.append(f" Version: {old_version} -> {new_version}")
518
634
  lines.append(f" Backup: {backup_dir}")
635
+ if not hook_sync_warning:
636
+ lines.append(" Hooks: synced to NEXO_HOME")
637
+ else:
638
+ lines.append(f" WARNING: hook sync: {hook_sync_warning}")
639
+ if retired_runtime_files:
640
+ lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
641
+ if not client_sync_warning:
642
+ lines.append(" Clients: configured client targets synced")
643
+ else:
644
+ lines.append(f" WARNING: client sync: {client_sync_warning}")
519
645
  lines.append("")
520
646
  lines.append("MCP server restart needed to load new code.")
521
647
  return "\n".join(lines)
@@ -627,9 +753,11 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
627
753
  cron_sync_result = f"Cron sync warning: {e}"
628
754
 
629
755
  # Step 9: Sync hooks to NEXO_HOME
756
+ retired_runtime_files: list[str] = []
630
757
  try:
631
758
  _emit_progress(progress_fn, "Syncing core Claude hooks...")
632
759
  _sync_hooks_to_home()
760
+ retired_runtime_files = _cleanup_retired_runtime_files()
633
761
  steps_done.append("hook-sync")
634
762
  except Exception as e:
635
763
  pass # Non-critical, log in function
@@ -681,6 +809,8 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
681
809
  lines.append(" Crons: synced with manifest")
682
810
  if "hook-sync" in steps_done:
683
811
  lines.append(" Hooks: synced to NEXO_HOME")
812
+ if retired_runtime_files:
813
+ lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
684
814
  if "client-sync" in steps_done:
685
815
  lines.append(" Clients: configured client targets synced")
686
816
  lines.append("")
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared helpers to resolve the managed NEXO home path."""
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def user_home() -> Path:
10
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
11
+
12
+
13
+ def managed_nexo_home(*, home: Path | None = None) -> Path:
14
+ return (home or user_home()) / ".nexo"
15
+
16
+
17
+ def legacy_nexo_home(*, home: Path | None = None) -> Path:
18
+ return (home or user_home()) / "claude"
19
+
20
+
21
+ def resolve_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
22
+ home = user_home()
23
+ managed = managed_nexo_home(home=home)
24
+ candidate = Path(value).expanduser() if value else Path(
25
+ os.environ.get("NEXO_HOME", str(managed))
26
+ ).expanduser()
27
+ legacy = legacy_nexo_home(home=home)
28
+
29
+ if candidate == managed:
30
+ return managed
31
+ if candidate == legacy:
32
+ return managed if managed.exists() or legacy.is_symlink() else candidate
33
+
34
+ try:
35
+ if managed.exists() and candidate.resolve(strict=False) == managed.resolve(strict=False):
36
+ return managed
37
+ except Exception:
38
+ pass
39
+
40
+ return candidate
41
+
42
+
43
+ def export_resolved_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
44
+ resolved = resolve_nexo_home(value)
45
+ os.environ["NEXO_HOME"] = str(resolved)
46
+ return resolved