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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/agent_runner.py +3 -0
- package/src/enforcement_engine.py +49 -32
- package/src/script_registry.py +22 -2
- package/src/scripts/check-context.py +1 -1
- package/src/scripts/deep-sleep/extract.py +2 -2
- package/src/scripts/deep-sleep/synthesize.py +1 -1
- package/src/scripts/nexo-catchup.py +1 -1
- package/src/scripts/nexo-daily-self-audit.py +1 -1
- package/src/scripts/nexo-evolution-run.py +2 -2
- package/src/scripts/nexo-immune.py +1 -1
- package/src/scripts/nexo-learning-validator.py +1 -1
- package/src/scripts/nexo-postmortem-consolidator.py +1 -1
- package/src/scripts/nexo-sleep.py +1 -1
- package/src/scripts/nexo-synthesis.py +1 -1
- package/src/tools_sessions.py +43 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.5.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "5.5.2",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
205
|
+
skip_next = True
|
|
188
206
|
continue
|
|
189
207
|
if arg == "--output-format":
|
|
190
|
-
skip_next = True
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
@@ -134,7 +134,7 @@ def analyze_session(
|
|
|
134
134
|
|
|
135
135
|
result = run_automation_prompt(
|
|
136
136
|
prompt,
|
|
137
|
-
model=_USER_MODEL
|
|
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
|
|
167
|
+
model=_USER_MODEL,
|
|
168
168
|
timeout=120,
|
|
169
169
|
output_format="text",
|
|
170
170
|
append_system_prompt=JSON_SYSTEM_PROMPT,
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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__*",
|
package/src/tools_sessions.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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:
|