nexo-brain 7.9.20 → 7.9.21

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.9.20",
3
+ "version": "7.9.21",
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,7 +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.9.20` is the current packaged-runtime line. Patch release over `7.9.19`: packaged update/doctor repair now finds `runtime/crons/sync.py`, LaunchAgent PATH includes the managed Claude runtime installed under `~/.nexo/runtime/bootstrap/npm-global/bin`, root runtime backfill includes `claude_cli.py`, and Immune no longer treats the legacy optional `~/.claude-mem/claude-mem.db` as a required database.
21
+ Version `7.9.21` is the current packaged-runtime line. Patch release over `7.9.20`: LaunchAgent reload/repair now handles macOS already-loaded races by booting out jobs with modern launchctl forms, falling back to legacy load, and treating an already-loaded job as healthy only when it points at the expected plist.
22
+
23
+ Previously in `7.9.20`: packaged update/doctor repair now finds `runtime/crons/sync.py`, LaunchAgent PATH includes the managed Claude runtime installed under `~/.nexo/runtime/bootstrap/npm-global/bin`, root runtime backfill includes `claude_cli.py`, and Immune no longer treats the legacy optional `~/.claude-mem/claude-mem.db` as a required database.
22
24
 
23
25
  Previously in `7.9.19`: runtime doctor now distinguishes real install breakage from tracked in-progress work, interactive Desktop sessions no longer poison automation telemetry scoring, stale filesystem skill rows are pruned during sync, stale protocol debt draining marks rows resolved, and watchdog treats LaunchAgent SIGTERM reloads as supervisor interruptions instead of failures.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.20",
3
+ "version": "7.9.21",
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",
@@ -1267,26 +1267,15 @@ def _reload_launch_agents_after_bump() -> dict:
1267
1267
  if not plist.is_file():
1268
1268
  result["skipped_missing"] += 1
1269
1269
  continue
1270
- # launchctl bootout / bootstrap is the modern API but requires
1271
- # the GUI session id ($UID/Background or gui/$UID). The legacy
1272
- # unload + load -w pair still works on every macOS NEXO supports
1273
- # and does not need a session id, so we use it here.
1274
- unload_proc = subprocess.run(
1275
- ["launchctl", "unload", str(plist)],
1276
- capture_output=True, text=True, timeout=10,
1277
- )
1278
- # unload returns non-zero if the agent was not loaded — that
1279
- # is fine, we still try to load fresh.
1280
- load_proc = subprocess.run(
1281
- ["launchctl", "load", "-w", str(plist)],
1282
- capture_output=True, text=True, timeout=10,
1283
- )
1284
- if load_proc.returncode == 0:
1270
+ from runtime_power import reload_launchagent_plist
1271
+
1272
+ reload_result = reload_launchagent_plist(plist)
1273
+ if reload_result.get("ok"):
1285
1274
  result["reloaded"] += 1
1286
1275
  else:
1287
1276
  result["errors"].append({
1288
1277
  "plist": plist.name,
1289
- "stderr": (load_proc.stderr or load_proc.stdout or "load failed")[:300],
1278
+ "stderr": str(reload_result.get("error") or "reload failed")[:300],
1290
1279
  })
1291
1280
  except subprocess.TimeoutExpired:
1292
1281
  result["errors"].append({"plist": plist.name, "stderr": "launchctl timeout"})
package/src/crons/sync.py CHANGED
@@ -34,7 +34,11 @@ if str(_runtime_root) not in sys.path:
34
34
  import paths
35
35
  from cron_recovery import is_cron_enabled, resolve_declared_schedule, should_run_at_load
36
36
  try:
37
- from runtime_power import resolve_launchagent_path
37
+ from runtime_power import (
38
+ reload_launchagent_plist,
39
+ resolve_launchagent_path,
40
+ unload_launchagent_plist,
41
+ )
38
42
  except ImportError:
39
43
  def resolve_launchagent_path() -> str:
40
44
  """Fallback when runtime_power is not importable."""
@@ -58,6 +62,17 @@ except ImportError:
58
62
  break
59
63
  return ":".join(parts)
60
64
 
65
+ def reload_launchagent_plist(plist_path: Path, label: str | None = None, timeout: int = 10) -> dict:
66
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
67
+ proc = subprocess.run(["launchctl", "load", "-w", str(plist_path)], capture_output=True, text=True, timeout=timeout)
68
+ if proc.returncode == 0:
69
+ return {"ok": True, "label": label or Path(plist_path).stem}
70
+ return {"ok": False, "label": label or Path(plist_path).stem, "error": proc.stderr or proc.stdout or "load failed"}
71
+
72
+ def unload_launchagent_plist(plist_path: Path, label: str | None = None, timeout: int = 10) -> dict:
73
+ proc = subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, timeout=timeout)
74
+ return {"ok": proc.returncode == 0, "label": label or Path(plist_path).stem}
75
+
61
76
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
62
77
  SOURCE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
63
78
  RUNTIME_ROOT = NEXO_HOME
@@ -437,14 +452,14 @@ def install_plist(label: str, plist: dict, plist_path: Path, dry_run: bool):
437
452
  log(f" DRY-RUN: would install {plist_path.name}")
438
453
  return
439
454
 
440
- # Unload if already loaded
441
- subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
442
-
443
455
  with open(plist_path, "wb") as f:
444
456
  plistlib.dump(plist, f)
445
457
 
446
- subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True)
447
- log(f" Installed + loaded: {plist_path.name}")
458
+ result = reload_launchagent_plist(plist_path, label=label)
459
+ if result.get("ok"):
460
+ log(f" Installed + loaded: {plist_path.name}")
461
+ else:
462
+ log(f" Installed but launchctl reload failed: {plist_path.name}: {result.get('error') or 'unknown error'}")
448
463
 
449
464
 
450
465
  def unload_plist(plist_path: Path, dry_run: bool):
@@ -453,7 +468,7 @@ def unload_plist(plist_path: Path, dry_run: bool):
453
468
  log(f" DRY-RUN: would remove {plist_path.name}")
454
469
  return
455
470
 
456
- subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
471
+ unload_launchagent_plist(plist_path)
457
472
  plist_path.unlink(missing_ok=True)
458
473
  log(f" Removed: {plist_path.name}")
459
474
 
@@ -32,6 +32,7 @@ from claude_cli import (
32
32
  )
33
33
  from cron_recovery import is_cron_enabled, resolve_declared_schedule, should_run_at_load
34
34
  from doctor.models import DoctorCheck, safe_check
35
+ from runtime_power import reload_launchagent_plist
35
36
 
36
37
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
37
38
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
@@ -1080,25 +1081,13 @@ def _recent_permission_denial(cron_id: str, max_age_seconds: int = 7 * 86400) ->
1080
1081
 
1081
1082
  def _repair_launchagents(items: list[tuple[str, Path]]) -> tuple[bool, list[str]]:
1082
1083
  evidence = []
1083
- uid = str(os.getuid())
1084
1084
  ok = True
1085
1085
  for cron_id, plist_path in items:
1086
1086
  label = f"com.nexo.{cron_id}"
1087
- subprocess.run(
1088
- ["launchctl", "bootout", f"gui/{uid}/{label}"],
1089
- capture_output=True,
1090
- text=True,
1091
- timeout=3,
1092
- )
1093
- result = subprocess.run(
1094
- ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
1095
- capture_output=True,
1096
- text=True,
1097
- timeout=5,
1098
- )
1099
- if result.returncode != 0:
1087
+ result = reload_launchagent_plist(plist_path, label=label, timeout=5)
1088
+ if not result.get("ok"):
1100
1089
  ok = False
1101
- evidence.append(f"{label}: {result.stderr.strip() or result.stdout.strip() or 'bootstrap failed'}")
1090
+ evidence.append(f"{label}: {result.get('error') or result.get('bootstrap_error') or 'reload failed'}")
1102
1091
  return ok, evidence
1103
1092
 
1104
1093
 
@@ -1043,25 +1043,16 @@ def _reload_launch_agents_after_bump() -> dict:
1043
1043
  if not plist.is_file():
1044
1044
  result["skipped_missing"] += 1
1045
1045
  continue
1046
- subprocess.run(
1047
- ["launchctl", "unload", str(plist)],
1048
- capture_output=True,
1049
- text=True,
1050
- timeout=10,
1051
- )
1052
- load_proc = subprocess.run(
1053
- ["launchctl", "load", "-w", str(plist)],
1054
- capture_output=True,
1055
- text=True,
1056
- timeout=10,
1057
- )
1058
- if load_proc.returncode == 0:
1046
+ from runtime_power import reload_launchagent_plist
1047
+
1048
+ reload_result = reload_launchagent_plist(plist)
1049
+ if reload_result.get("ok"):
1059
1050
  result["reloaded"] += 1
1060
1051
  else:
1061
1052
  result["errors"].append(
1062
1053
  {
1063
1054
  "plist": plist.name,
1064
- "stderr": (load_proc.stderr or load_proc.stdout or "load failed")[:300],
1055
+ "stderr": str(reload_result.get("error") or "reload failed")[:300],
1065
1056
  }
1066
1057
  )
1067
1058
  except subprocess.TimeoutExpired:
@@ -17,9 +17,11 @@ import json
17
17
  import os
18
18
  import paths
19
19
  import platform
20
+ import plistlib
20
21
  import shutil
21
22
  import subprocess
22
23
  import sys
24
+ import time
23
25
  from pathlib import Path
24
26
 
25
27
 
@@ -101,6 +103,163 @@ def resolve_launchagent_path() -> str:
101
103
  return ":".join(parts)
102
104
 
103
105
 
106
+ def launchagent_label_from_plist(plist_path: Path, label: str | None = None) -> str:
107
+ """Resolve a launchd label from a plist, falling back to the filename."""
108
+ if label:
109
+ return label
110
+ try:
111
+ with Path(plist_path).open("rb") as fh:
112
+ payload = plistlib.load(fh)
113
+ plist_label = payload.get("Label")
114
+ if plist_label:
115
+ return str(plist_label)
116
+ except Exception:
117
+ pass
118
+ name = Path(plist_path).stem
119
+ return name
120
+
121
+
122
+ def _launchctl_text(proc: subprocess.CompletedProcess) -> str:
123
+ return (proc.stderr or proc.stdout or "").strip()
124
+
125
+
126
+ def _launchctl_print(label: str, timeout: int = 5) -> subprocess.CompletedProcess:
127
+ return subprocess.run(
128
+ ["launchctl", "print", f"gui/{os.getuid()}/{label}"],
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=timeout,
132
+ )
133
+
134
+
135
+ def launchagent_loaded_from_plist(plist_path: Path, label: str | None = None) -> bool:
136
+ """Return True when launchd has the label loaded from this exact plist."""
137
+ plist_path = Path(plist_path)
138
+ resolved_label = launchagent_label_from_plist(plist_path, label)
139
+ try:
140
+ proc = _launchctl_print(resolved_label)
141
+ except Exception:
142
+ return False
143
+ if proc.returncode != 0:
144
+ return False
145
+ stdout = proc.stdout or ""
146
+ candidates = {
147
+ str(plist_path),
148
+ str(plist_path.expanduser()),
149
+ str(plist_path.resolve(strict=False)),
150
+ }
151
+ return any(f"path = {candidate}" in stdout or candidate in stdout for candidate in candidates)
152
+
153
+
154
+ def unload_launchagent_plist(
155
+ plist_path: Path,
156
+ label: str | None = None,
157
+ timeout: int = 10,
158
+ wait_seconds: float = 0.25,
159
+ ) -> dict:
160
+ """Best-effort unload using modern and legacy launchctl forms.
161
+
162
+ macOS can report a generic bootstrap error when a job is still loaded.
163
+ We therefore boot out by label, boot out by plist path, and finally call
164
+ the legacy unload form for older installs.
165
+ """
166
+ plist_path = Path(plist_path)
167
+ resolved_label = launchagent_label_from_plist(plist_path, label)
168
+ domain = f"gui/{os.getuid()}"
169
+ commands = [
170
+ ["launchctl", "bootout", f"{domain}/{resolved_label}"],
171
+ ["launchctl", "bootout", domain, str(plist_path)],
172
+ ["launchctl", "unload", str(plist_path)],
173
+ ]
174
+ evidence: list[str] = []
175
+ for command in commands:
176
+ try:
177
+ proc = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
178
+ if proc.returncode != 0:
179
+ text = _launchctl_text(proc)
180
+ if text:
181
+ evidence.append(text[:300])
182
+ except subprocess.TimeoutExpired:
183
+ evidence.append("launchctl timeout")
184
+ except Exception as exc:
185
+ evidence.append(str(exc)[:300])
186
+
187
+ deadline = time.monotonic() + max(0.0, wait_seconds)
188
+ while time.monotonic() < deadline:
189
+ if not launchagent_loaded_from_plist(plist_path, resolved_label):
190
+ return {"ok": True, "label": resolved_label, "errors": evidence}
191
+ time.sleep(0.05)
192
+
193
+ still_loaded = launchagent_loaded_from_plist(plist_path, resolved_label)
194
+ return {"ok": not still_loaded, "label": resolved_label, "errors": evidence}
195
+
196
+
197
+ def reload_launchagent_plist(
198
+ plist_path: Path,
199
+ label: str | None = None,
200
+ timeout: int = 10,
201
+ ) -> dict:
202
+ """Reload one LaunchAgent and tolerate already-loaded launchd races."""
203
+ plist_path = Path(plist_path)
204
+ resolved_label = launchagent_label_from_plist(plist_path, label)
205
+ if not plist_path.is_file():
206
+ return {"ok": False, "label": resolved_label, "error": "plist missing"}
207
+
208
+ unload_launchagent_plist(plist_path, resolved_label, timeout=timeout)
209
+ domain = f"gui/{os.getuid()}"
210
+ try:
211
+ bootstrap = subprocess.run(
212
+ ["launchctl", "bootstrap", domain, str(plist_path)],
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=timeout,
216
+ )
217
+ except subprocess.TimeoutExpired:
218
+ return {"ok": False, "label": resolved_label, "error": "launchctl timeout"}
219
+ except Exception as exc:
220
+ return {"ok": False, "label": resolved_label, "error": str(exc)[:300]}
221
+
222
+ if bootstrap.returncode == 0:
223
+ return {"ok": True, "label": resolved_label, "action": "bootstrap"}
224
+
225
+ bootstrap_error = _launchctl_text(bootstrap) or "bootstrap failed"
226
+ if launchagent_loaded_from_plist(plist_path, resolved_label):
227
+ return {
228
+ "ok": True,
229
+ "label": resolved_label,
230
+ "action": "already-loaded",
231
+ "warning": bootstrap_error[:300],
232
+ }
233
+
234
+ try:
235
+ fallback = subprocess.run(
236
+ ["launchctl", "load", "-w", str(plist_path)],
237
+ capture_output=True,
238
+ text=True,
239
+ timeout=timeout,
240
+ )
241
+ except subprocess.TimeoutExpired:
242
+ return {"ok": False, "label": resolved_label, "error": "launchctl timeout"}
243
+ except Exception as exc:
244
+ return {"ok": False, "label": resolved_label, "error": str(exc)[:300]}
245
+
246
+ if fallback.returncode == 0 or launchagent_loaded_from_plist(plist_path, resolved_label):
247
+ return {
248
+ "ok": True,
249
+ "label": resolved_label,
250
+ "action": "legacy-load",
251
+ "warning": bootstrap_error[:300],
252
+ }
253
+
254
+ fallback_error = _launchctl_text(fallback) or "load failed"
255
+ return {
256
+ "ok": False,
257
+ "label": resolved_label,
258
+ "error": fallback_error[:300],
259
+ "bootstrap_error": bootstrap_error[:300],
260
+ }
261
+
262
+
104
263
  def _schedule_defaults() -> dict:
105
264
  return {
106
265
  "timezone": "UTC",