nexo-brain 5.5.0 → 5.5.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": "5.5.0",
3
+ "version": "5.5.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,7 +18,7 @@
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 `5.5.0` is the current packaged-runtime line: headless Protocol Enforcer all crons get enforcement rules automatically.
21
+ Version `5.5.2` is the current packaged-runtime line: auto-repair unloaded LaunchAgents on startup and ensure_schedules, plus headless model fallback cleanup so automation scripts defer to the configured runtime profile.
22
22
 
23
23
  Previously in `5.4.6`: runtime dependency management in `nexo update` + daily auto-update cron.
24
24
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.5.0",
3
+ "version": "5.5.2",
4
4
  "mcpName": "io.github.wazionapps/nexo",
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.",
5
+ "description": "NEXO Brain \u2014 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",
7
7
  "bin": {
8
8
  "nexo-brain": "./bin/nexo-brain.js",
@@ -663,6 +663,9 @@ def run_automation_prompt(
663
663
  cmd.extend(["--allowedTools", allowed_tools])
664
664
  cmd.extend(extra_args)
665
665
  try:
666
+ import sys as _sys
667
+ if str(NEXO_HOME) not in _sys.path:
668
+ _sys.path.insert(0, str(NEXO_HOME))
666
669
  from enforcement_engine import run_with_enforcement
667
670
  result = run_with_enforcement(
668
671
  cmd,
@@ -8,6 +8,7 @@ are violated. Python equivalent of Desktop's enforcement-engine.js.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import logging
11
12
  import os
12
13
  import subprocess
13
14
  import threading
@@ -16,6 +17,15 @@ from pathlib import Path
16
17
 
17
18
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
19
  MAP_FILENAME = "tool-enforcement-map.json"
20
+ LOG_DIR = NEXO_HOME / "logs"
21
+
22
+ _logger = logging.getLogger("nexo.enforcer")
23
+ if not _logger.handlers:
24
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
25
+ _fh = logging.FileHandler(LOG_DIR / "enforcer-headless.log")
26
+ _fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
27
+ _logger.addHandler(_fh)
28
+ _logger.setLevel(logging.INFO)
19
29
 
20
30
 
21
31
  def _load_map() -> dict | None:
@@ -48,8 +58,8 @@ class HeadlessEnforcer:
48
58
  self.msg_since_tool: dict[str, int] = {}
49
59
  self.injection_queue: list[dict] = []
50
60
  self._started_at = time.time()
61
+ self._injections_done = 0
51
62
 
52
- # Build indexes from map
53
63
  self._on_start: list[dict] = []
54
64
  self._on_end: list[dict] = []
55
65
  self._periodic_msg: list[dict] = []
@@ -58,6 +68,11 @@ class HeadlessEnforcer:
58
68
 
59
69
  if self.map:
60
70
  self._build_indexes()
71
+ _logger.info("Map v%s loaded: %d on_start, %d on_end, %d periodic_msg, %d periodic_time, %d after_tool",
72
+ self.map.get("version", "?"), len(self._on_start), len(self._on_end),
73
+ len(self._periodic_msg), len(self._periodic_time), len(self._after_tool))
74
+ else:
75
+ _logger.warning("No enforcement map found")
61
76
 
62
77
  def _build_indexes(self):
63
78
  for tool_name, tool_def in self.map.get("tools", {}).items():
@@ -79,7 +94,6 @@ class HeadlessEnforcer:
79
94
  for wt in rule.get("watch_tools", []):
80
95
  self._after_tool.setdefault(wt, []).append(entry)
81
96
 
82
- # triggers_after chains
83
97
  for triggered in enf.get("triggers_after", []):
84
98
  self._after_tool.setdefault(tool_name, []).append({
85
99
  "tool": triggered,
@@ -93,8 +107,8 @@ class HeadlessEnforcer:
93
107
  self.tools_called.add(name)
94
108
  self.tool_timestamps[name] = time.time()
95
109
  self.msg_since_tool[name] = 0
110
+ _logger.info("TOOL_CALL #%d: %s", self.tool_call_count, name)
96
111
 
97
- # Check after_tool rules
98
112
  for entry in self._after_tool.get(name, []):
99
113
  target = entry["tool"]
100
114
  if target not in self.tools_called:
@@ -103,8 +117,6 @@ class HeadlessEnforcer:
103
117
  self._enqueue(prompt, f"after:{name}->{target}")
104
118
 
105
119
  def check_periodic(self):
106
- """Called periodically to check time-based and message-based rules."""
107
- # on_session_start
108
120
  for entry in self._on_start:
109
121
  tool = entry["tool"]
110
122
  threshold = entry["rule"].get("threshold", 2)
@@ -113,7 +125,6 @@ class HeadlessEnforcer:
113
125
  if prompt:
114
126
  self._enqueue(prompt, f"start:{tool}")
115
127
 
116
- # periodic_by_messages
117
128
  for entry in self._periodic_msg:
118
129
  tool = entry["tool"]
119
130
  threshold = entry["rule"].get("threshold", 3)
@@ -123,7 +134,6 @@ class HeadlessEnforcer:
123
134
  if prompt:
124
135
  self._enqueue(prompt, f"periodic_msg:{tool}")
125
136
 
126
- # periodic_by_time
127
137
  for entry in self._periodic_time:
128
138
  tool = entry["tool"]
129
139
  threshold_min = entry["rule"].get("threshold", 15)
@@ -141,6 +151,7 @@ class HeadlessEnforcer:
141
151
  p = entry["enf"].get("session_end_inject_prompt") or entry["enf"].get("inject_prompt", "")
142
152
  if p:
143
153
  prompts.append(p)
154
+ _logger.info("END_PROMPTS: %d prompts to inject", len(prompts))
144
155
  return prompts
145
156
 
146
157
  def flush(self) -> dict | None:
@@ -152,9 +163,20 @@ class HeadlessEnforcer:
152
163
  if any(q["tag"] == tag for q in self.injection_queue):
153
164
  return
154
165
  tool = tag.split(":")[-1].split("->")[-1]
166
+ last_called = self.tool_timestamps.get(tool)
167
+ if last_called and tool in self.tools_called:
168
+ if time.time() - last_called < 60:
169
+ _logger.info("DEDUP_SKIP: %s — %s called %ds ago", tag, tool, int(time.time() - last_called))
170
+ return
155
171
  if tool in self.tools_called and not tag.startswith("periodic_"):
172
+ _logger.info("SKIP: %s — already called", tag)
156
173
  return
157
174
  self.injection_queue.append({"prompt": prompt, "tag": tag, "at": time.time()})
175
+ _logger.info("ENQUEUED: %s (queue size: %d)", tag, len(self.injection_queue))
176
+
177
+ def summary(self) -> str:
178
+ return (f"tools_called={len(self.tools_called)} tool_calls={self.tool_call_count} "
179
+ f"injections={self._injections_done} tools={sorted(self.tools_called)}")
158
180
 
159
181
 
160
182
  def run_with_enforcement(
@@ -165,18 +187,14 @@ def run_with_enforcement(
165
187
  env: dict | None = None,
166
188
  timeout: int = 300,
167
189
  ) -> subprocess.CompletedProcess:
168
- """Run a Claude Code process with real-time enforcement monitoring.
169
-
170
- Uses stream-json mode instead of -p mode. Monitors stdout for tool calls,
171
- injects enforcement prompts via stdin when rules are violated.
172
- """
173
190
  enforcer = HeadlessEnforcer()
191
+ _logger.info("=== SESSION START === prompt=%s timeout=%d", prompt[:80], timeout)
192
+
174
193
  if not enforcer.map:
175
- # No map available fall back to simple subprocess.run
194
+ _logger.warning("No map — falling back to plain subprocess.run")
176
195
  return subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True,
177
196
  timeout=timeout, env=env)
178
197
 
179
- # Replace -p with stream-json mode
180
198
  stream_cmd = []
181
199
  skip_next = False
182
200
  for i, arg in enumerate(cmd):
@@ -184,10 +202,10 @@ def run_with_enforcement(
184
202
  skip_next = False
185
203
  continue
186
204
  if arg == "-p":
187
- skip_next = True # Skip the prompt argument
205
+ skip_next = True
188
206
  continue
189
207
  if arg == "--output-format":
190
- skip_next = True # Skip output format
208
+ skip_next = True
191
209
  continue
192
210
  stream_cmd.append(arg)
193
211
 
@@ -208,7 +226,6 @@ def run_with_enforcement(
208
226
  text=True,
209
227
  )
210
228
 
211
- # Send initial user message
212
229
  initial_msg = json.dumps({
213
230
  "type": "user",
214
231
  "message": {"role": "user", "content": [{"type": "text", "text": prompt}]}
@@ -231,8 +248,10 @@ def run_with_enforcement(
231
248
  proc.stdin.write(msg + "\n")
232
249
  proc.stdin.flush()
233
250
  waiting_for_injection_response = True
234
- except Exception:
235
- pass
251
+ enforcer._injections_done += 1
252
+ _logger.info("INJECTED: %s", text[:100])
253
+ except Exception as e:
254
+ _logger.error("INJECT_FAILED: %s", e)
236
255
 
237
256
  def _read_stderr():
238
257
  try:
@@ -244,7 +263,6 @@ def run_with_enforcement(
244
263
  stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
245
264
  stderr_thread.start()
246
265
 
247
- # Periodic check timer
248
266
  last_periodic_check = time.time()
249
267
 
250
268
  try:
@@ -253,8 +271,8 @@ def run_with_enforcement(
253
271
  if not line:
254
272
  continue
255
273
 
256
- # Check timeout
257
274
  if time.time() - start_time > timeout:
275
+ _logger.warning("TIMEOUT after %ds", timeout)
258
276
  proc.kill()
259
277
  break
260
278
 
@@ -265,7 +283,6 @@ def run_with_enforcement(
265
283
 
266
284
  event_type = event.get("type", "")
267
285
 
268
- # Detect tool use
269
286
  if event_type == "assistant" and event.get("message", {}).get("content"):
270
287
  for block in event["message"]["content"]:
271
288
  if block.get("type") == "tool_use":
@@ -275,48 +292,44 @@ def run_with_enforcement(
275
292
  if cb.get("type") == "tool_use":
276
293
  enforcer.on_tool_call(cb.get("name", ""))
277
294
 
278
- # Collect assistant text
279
295
  if event_type == "assistant" and not waiting_for_injection_response:
280
296
  msg = event.get("message", {})
281
297
  for block in msg.get("content", []):
282
298
  if block.get("type") == "text":
283
299
  collected_text.append(block["text"])
284
300
 
285
- # Detect turn end (result event with stop_reason)
286
301
  if event_type == "result":
287
302
  if waiting_for_injection_response:
288
303
  waiting_for_injection_response = False
289
- # After injection response, check for more injections
304
+ _logger.info("INJECTION_RESPONSE received")
290
305
  item = enforcer.flush()
291
306
  if item:
292
307
  _inject(item["prompt"])
293
308
  continue
294
309
 
295
- # Normal turn end — check for pending enforcements
296
310
  enforcer.check_periodic()
297
311
  item = enforcer.flush()
298
312
  if item:
299
313
  _inject(item["prompt"])
300
314
  else:
301
- break # No more injections, we're done
315
+ _logger.info("TURN_END no pending enforcements, done")
316
+ break
302
317
 
303
- # Periodic check every 30 seconds
304
318
  if time.time() - last_periodic_check > 30:
305
319
  enforcer.check_periodic()
306
320
  last_periodic_check = time.time()
307
321
 
308
- except Exception:
309
- pass
322
+ except Exception as e:
323
+ _logger.error("EXCEPTION: %s", e)
310
324
  finally:
311
- # End-of-session enforcement: inject diary + stop
312
325
  end_prompts = enforcer.get_end_prompts()
313
326
  for ep in end_prompts:
314
327
  try:
315
328
  _inject(ep)
316
- # Wait briefly for response
317
329
  deadline = time.time() + 15
318
330
  for raw_line in proc.stdout:
319
331
  if time.time() > deadline:
332
+ _logger.warning("END_PROMPT timeout")
320
333
  break
321
334
  line = raw_line.strip()
322
335
  if not line:
@@ -324,12 +337,16 @@ def run_with_enforcement(
324
337
  try:
325
338
  event = json.loads(line)
326
339
  if event.get("type") == "result":
340
+ _logger.info("END_PROMPT response received")
327
341
  break
328
342
  except json.JSONDecodeError:
329
343
  continue
330
344
  except Exception:
331
345
  break
332
346
 
347
+ elapsed = time.time() - start_time
348
+ _logger.info("=== SESSION END === duration=%.1fs %s", elapsed, enforcer.summary())
349
+
333
350
  try:
334
351
  proc.stdin.close()
335
352
  except Exception:
@@ -1206,11 +1206,31 @@ def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
1206
1206
  existing = schedules_by_path.get(script["path"], [])
1207
1207
  matching = next((item for item in existing if item.get("schedule_managed") and _schedule_matches(item, declared)), None)
1208
1208
  if matching:
1209
- report["already_present"].append({
1209
+ entry = {
1210
1210
  "name": script["name"],
1211
1211
  "cron_id": matching["cron_id"],
1212
1212
  "schedule_label": matching.get("schedule_label", ""),
1213
- })
1213
+ }
1214
+ plist_path = matching.get("plist_path", "")
1215
+ if plist_path and platform.system() == "Darwin" and Path(plist_path).exists():
1216
+ label = matching.get("launchd_label") or f"com.nexo.{matching['cron_id']}"
1217
+ svc = _launchctl_service_state(label)
1218
+ if not svc.get("loaded"):
1219
+ if not dry_run:
1220
+ result = subprocess.run(
1221
+ ["launchctl", "bootstrap", f"gui/{os.getuid()}", plist_path],
1222
+ capture_output=True, timeout=5,
1223
+ )
1224
+ if result.returncode == 0:
1225
+ entry["reloaded"] = True
1226
+ entry["reason"] = "plist on disk but not loaded in launchd"
1227
+ else:
1228
+ entry["reload_failed"] = True
1229
+ entry["reason"] = result.stderr.decode(errors="replace").strip() or "bootstrap failed"
1230
+ else:
1231
+ entry["reloaded"] = True
1232
+ entry["reason"] = "plist on disk but not loaded in launchd (dry_run)"
1233
+ report["already_present"].append(entry)
1214
1234
  continue
1215
1235
 
1216
1236
  repair_reasons = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in existing]
@@ -198,7 +198,7 @@ Rules:
198
198
  try:
199
199
  result = run_automation_prompt(
200
200
  prompt,
201
- model=_USER_MODEL or "opus",
201
+ model=_USER_MODEL,
202
202
  timeout=300,
203
203
  output_format="text",
204
204
  append_system_prompt="Return exactly one valid JSON object.",
@@ -134,7 +134,7 @@ def analyze_session(
134
134
 
135
135
  result = run_automation_prompt(
136
136
  prompt,
137
- model=_USER_MODEL or "opus",
137
+ model=_USER_MODEL,
138
138
  timeout=CLAUDE_TIMEOUT,
139
139
  output_format="text",
140
140
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -164,7 +164,7 @@ def analyze_session(
164
164
  )
165
165
  convert_result = run_automation_prompt(
166
166
  convert_prompt,
167
- model=_USER_MODEL or "sonnet",
167
+ model=_USER_MODEL,
168
168
  timeout=120,
169
169
  output_format="text",
170
170
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -240,7 +240,7 @@ def main():
240
240
  try:
241
241
  result = run_automation_prompt(
242
242
  prompt,
243
- model=_USER_MODEL or "opus",
243
+ model=_USER_MODEL,
244
244
  timeout=CLAUDE_TIMEOUT,
245
245
  output_format="text",
246
246
  allowed_tools="Read,Grep,Bash",
@@ -279,7 +279,7 @@ Format:
279
279
  try:
280
280
  result = run_automation_prompt(
281
281
  prompt,
282
- model=_USER_MODEL or "opus",
282
+ model=_USER_MODEL,
283
283
  timeout=21600,
284
284
  output_format="text",
285
285
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -2049,7 +2049,7 @@ Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
2049
2049
  try:
2050
2050
  result = run_automation_prompt(
2051
2051
  prompt,
2052
- model=_USER_MODEL or "opus",
2052
+ model=_USER_MODEL,
2053
2053
  timeout=21600,
2054
2054
  output_format="text",
2055
2055
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -226,7 +226,7 @@ def call_claude_cli(prompt: str) -> str:
226
226
  """Call the configured automation backend for the managed evolution prompt."""
227
227
  result = run_automation_prompt(
228
228
  prompt,
229
- model=_USER_MODEL or "opus",
229
+ model=_USER_MODEL,
230
230
  timeout=CLI_TIMEOUT,
231
231
  output_format="text",
232
232
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -242,7 +242,7 @@ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
242
242
  prompt,
243
243
  cwd=cwd,
244
244
  env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
245
- model=_USER_MODEL or "opus",
245
+ model=_USER_MODEL,
246
246
  timeout=CLI_TIMEOUT,
247
247
  output_format="text",
248
248
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
@@ -915,7 +915,7 @@ Write the report. Be concise — max 40 lines."""
915
915
  try:
916
916
  result = run_automation_prompt(
917
917
  prompt,
918
- model=_USER_MODEL or "opus",
918
+ model=_USER_MODEL,
919
919
  timeout=21600,
920
920
  output_format="text",
921
921
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -158,7 +158,7 @@ Rules:
158
158
  try:
159
159
  result = run_automation_prompt(
160
160
  prompt,
161
- model=_USER_MODEL or "sonnet",
161
+ model=_USER_MODEL,
162
162
  timeout=60,
163
163
  output_format="text",
164
164
  append_system_prompt=JSON_ONLY_SYSTEM_PROMPT,
@@ -254,7 +254,7 @@ Execute without asking."""
254
254
  try:
255
255
  result = run_automation_prompt(
256
256
  prompt,
257
- model=_USER_MODEL or "opus",
257
+ model=_USER_MODEL,
258
258
  timeout=21600,
259
259
  output_format="text",
260
260
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -445,7 +445,7 @@ Execute without asking."""
445
445
  try:
446
446
  result = run_automation_prompt(
447
447
  prompt,
448
- model=_USER_MODEL or "opus",
448
+ model=_USER_MODEL,
449
449
  timeout=21600,
450
450
  output_format="text",
451
451
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -347,7 +347,7 @@ Execute without asking."""
347
347
  try:
348
348
  result = run_automation_prompt(
349
349
  prompt,
350
- model=_USER_MODEL or "opus",
350
+ model=_USER_MODEL,
351
351
  timeout=21600,
352
352
  output_format="text",
353
353
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -332,10 +332,9 @@ def handle_startup(
332
332
  la_warnings = _check_launchagents()
333
333
  if la_warnings:
334
334
  lines.append("")
335
- lines.append("⚠ LAUNCHAGENT MISMATCH (plist on disk ≠ loaded in memory):")
335
+ lines.append("⚠ LAUNCHAGENT HEALTH:")
336
336
  for w in la_warnings:
337
337
  lines.append(f" {w}")
338
- lines.append(" Fix: launchctl unload + load the affected plists, or restart.")
339
338
 
340
339
  return "\n".join(lines)
341
340
 
@@ -351,6 +350,12 @@ def _check_launchagents() -> list[str]:
351
350
  plist_dir = os.path.expanduser("~/Library/LaunchAgents")
352
351
  warnings = []
353
352
 
353
+ def _stderr_text(result, fallback: str) -> str:
354
+ stderr = getattr(result, "stderr", "")
355
+ if isinstance(stderr, bytes):
356
+ stderr = stderr.decode(errors="replace")
357
+ return stderr.strip() or fallback
358
+
354
359
  for plist_path in glob.glob(os.path.join(plist_dir, "com.nexo.*.plist")):
355
360
  label = os.path.basename(plist_path).replace(".plist", "")
356
361
  try:
@@ -363,7 +368,17 @@ def _check_launchagents() -> list[str]:
363
368
  capture_output=True, text=True, timeout=5
364
369
  )
365
370
  if result.returncode != 0:
366
- warnings.append(f"{label}: not loaded (plist exists on disk)")
371
+ repair = subprocess.run(
372
+ ["launchctl", "bootstrap", f"gui/{os.getuid()}", plist_path],
373
+ capture_output=True, text=True, timeout=5,
374
+ )
375
+ if repair.returncode == 0:
376
+ warnings.append(f"{label}: AUTO-REPAIRED (was not loaded, reloaded from disk)")
377
+ else:
378
+ warnings.append(
379
+ f"{label}: REPAIR FAILED — "
380
+ f"{_stderr_text(repair, 'not loaded (plist exists on disk)')}"
381
+ )
367
382
  continue
368
383
 
369
384
  # Parse loaded ProgramArguments from launchctl output
@@ -384,10 +399,31 @@ def _check_launchagents() -> list[str]:
384
399
  # Check if loaded path points to /tmp or nonexistent path
385
400
  stale = any("/tmp/" in a or not os.path.exists(a) for a in loaded_args if "/" in a)
386
401
  if stale:
387
- # Auto-repair: reload the plist
388
- subprocess.run(["launchctl", "unload", plist_path], capture_output=True, timeout=5)
389
- subprocess.run(["launchctl", "load", plist_path], capture_output=True, timeout=5)
390
- warnings.append(f"{label}: AUTO-REPAIRED (was pointing to stale/tmp path, reloaded from disk)")
402
+ bootout = subprocess.run(
403
+ ["launchctl", "bootout", f"gui/{os.getuid()}/{label}"],
404
+ capture_output=True,
405
+ text=True,
406
+ timeout=5,
407
+ )
408
+ if bootout.returncode != 0:
409
+ warnings.append(
410
+ f"{label}: REPAIR FAILED — "
411
+ f"{_stderr_text(bootout, 'could not unload stale launchd entry')}"
412
+ )
413
+ continue
414
+ repair = subprocess.run(
415
+ ["launchctl", "bootstrap", f"gui/{os.getuid()}", plist_path],
416
+ capture_output=True,
417
+ text=True,
418
+ timeout=5,
419
+ )
420
+ if repair.returncode == 0:
421
+ warnings.append(f"{label}: AUTO-REPAIRED (was pointing to stale/tmp path, reloaded from disk)")
422
+ else:
423
+ warnings.append(
424
+ f"{label}: REPAIR FAILED — "
425
+ f"{_stderr_text(repair, 'could not reload stale plist from disk')}"
426
+ )
391
427
  else:
392
428
  warnings.append(f"{label}: loaded args differ from disk plist")
393
429
  except Exception: