nexo-brain 7.9.20 → 7.9.22
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 +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +5 -16
- package/src/cli.py +25 -0
- package/src/crons/sync.py +22 -7
- package/src/doctor/providers/runtime.py +4 -15
- package/src/lifecycle_events.py +132 -0
- package/src/plugins/lifecycle_events.py +26 -0
- package/src/plugins/update.py +5 -14
- package/src/runtime_power.py +159 -0
- package/src/runtime_versioning.py +1 -0
- package/tool-enforcement-map.json +13 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.22",
|
|
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,11 @@
|
|
|
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.
|
|
21
|
+
Version `7.9.22` is the current packaged-runtime line. Patch release over `7.9.21`: Desktop lifecycle shutdowns now have an emergency Brain-side fallback diary path, so close/archive/app-exit can preserve title, goal, session ids, and transcript tail even when the live agent does not answer the injected diary prompt before shutdown.
|
|
22
|
+
|
|
23
|
+
Previously in `7.9.21`: 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.
|
|
24
|
+
|
|
25
|
+
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
26
|
|
|
23
27
|
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
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.22",
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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": (
|
|
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/cli.py
CHANGED
|
@@ -3232,6 +3232,14 @@ def main():
|
|
|
3232
3232
|
lwait_p.add_argument("--timeout-ms", type=int, default=45_000)
|
|
3233
3233
|
lwait_p.add_argument("--poll-ms", type=int, default=500)
|
|
3234
3234
|
|
|
3235
|
+
lfallback_diary_p = lifecycle_sub.add_parser(
|
|
3236
|
+
"write-fallback-diary",
|
|
3237
|
+
help="v7.9.22: write emergency diary evidence for a lifecycle event",
|
|
3238
|
+
)
|
|
3239
|
+
lfallback_diary_p.add_argument("--event-id", required=True)
|
|
3240
|
+
lfallback_diary_p.add_argument("--reason", default="")
|
|
3241
|
+
lfallback_diary_p.add_argument("--source", default="desktop-lifecycle-fallback")
|
|
3242
|
+
|
|
3235
3243
|
lwait_stop_p = lifecycle_sub.add_parser(
|
|
3236
3244
|
"wait-for-stop",
|
|
3237
3245
|
help="v7.9.10: wait until the linked NEXO session is no longer active",
|
|
@@ -3547,6 +3555,23 @@ def main():
|
|
|
3547
3555
|
if status == "retryable_error":
|
|
3548
3556
|
return 2
|
|
3549
3557
|
return 3
|
|
3558
|
+
if args.lifecycle_command == "write-fallback-diary":
|
|
3559
|
+
out = _lifecycle_plugin.handle_nexo_lifecycle_write_fallback_diary(
|
|
3560
|
+
event_id=args.event_id,
|
|
3561
|
+
reason=args.reason or "",
|
|
3562
|
+
source=args.source or "desktop-lifecycle-fallback",
|
|
3563
|
+
)
|
|
3564
|
+
print(out)
|
|
3565
|
+
try:
|
|
3566
|
+
parsed = _json.loads(out)
|
|
3567
|
+
status = str(parsed.get("status", ""))
|
|
3568
|
+
except Exception:
|
|
3569
|
+
status = ""
|
|
3570
|
+
if status in ("ok", "processed", "already_processed"):
|
|
3571
|
+
return 0
|
|
3572
|
+
if status == "retryable_error":
|
|
3573
|
+
return 2
|
|
3574
|
+
return 3
|
|
3550
3575
|
if args.lifecycle_command == "wait-for-stop":
|
|
3551
3576
|
out = _lifecycle_plugin.handle_nexo_lifecycle_wait_for_stop(
|
|
3552
3577
|
event_id=args.event_id,
|
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
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
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.
|
|
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
|
|
package/src/lifecycle_events.py
CHANGED
|
@@ -260,6 +260,138 @@ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str], ac
|
|
|
260
260
|
return _session_diary_evidence(conn, session_id, dispatched_at, actions_json) is not None
|
|
261
261
|
|
|
262
262
|
|
|
263
|
+
def _preferred_diary_session_id(conn, session_id: str) -> str:
|
|
264
|
+
"""Return the best session id to store fallback diary evidence under."""
|
|
265
|
+
raw = str(session_id or "").strip()
|
|
266
|
+
candidates = _session_diary_session_ids(conn, raw)
|
|
267
|
+
for sid in candidates:
|
|
268
|
+
if str(sid or "").startswith("nexo-"):
|
|
269
|
+
return str(sid)
|
|
270
|
+
return candidates[0] if candidates else raw
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _payload_lines(payload: Dict[str, Any]) -> List[str]:
|
|
274
|
+
lines: List[str] = []
|
|
275
|
+
if not isinstance(payload, dict):
|
|
276
|
+
return lines
|
|
277
|
+
for raw in payload.get("transcript_tail") or []:
|
|
278
|
+
text = str(raw or "").strip()
|
|
279
|
+
if text:
|
|
280
|
+
lines.append(text)
|
|
281
|
+
if not lines:
|
|
282
|
+
user = str(payload.get("last_user_message") or payload.get("latest_user_text") or "").strip()
|
|
283
|
+
assistant = str(payload.get("last_assistant_message") or payload.get("latest_assistant_text") or "").strip()
|
|
284
|
+
if user:
|
|
285
|
+
lines.append(f"user: {user}")
|
|
286
|
+
if assistant:
|
|
287
|
+
lines.append(f"assistant: {assistant}")
|
|
288
|
+
return lines[-12:]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def write_fallback_diary_for_lifecycle_event(
|
|
292
|
+
event_id: str,
|
|
293
|
+
reason: str = "",
|
|
294
|
+
source: str = "desktop-lifecycle-fallback",
|
|
295
|
+
) -> Dict[str, Any]:
|
|
296
|
+
"""Write minimum durable diary evidence when live-agent injection fails.
|
|
297
|
+
|
|
298
|
+
This is the safety net for Desktop close/archive/app-exit. The preferred
|
|
299
|
+
path remains an agent-authored ``nexo_session_diary_write``. If the agent is
|
|
300
|
+
busy or stdin never produces a response, Desktop can call this command so
|
|
301
|
+
session continuity still has a concrete ``session_diary`` row instead of
|
|
302
|
+
silently losing the last context.
|
|
303
|
+
"""
|
|
304
|
+
if not event_id:
|
|
305
|
+
return {"status": "rejected", "reason": "missing-event-id"}
|
|
306
|
+
|
|
307
|
+
conn = get_db()
|
|
308
|
+
row = conn.execute(
|
|
309
|
+
"SELECT action, conversation_id, session_id, reason, payload_snapshot, "
|
|
310
|
+
"canonical_dispatched_at, canonical_actions_json "
|
|
311
|
+
"FROM lifecycle_events WHERE event_id = ?",
|
|
312
|
+
(str(event_id),),
|
|
313
|
+
).fetchone()
|
|
314
|
+
if row is None:
|
|
315
|
+
return {"status": "rejected", "reason": "unknown-event-id", "event_id": event_id}
|
|
316
|
+
|
|
317
|
+
action = str(row[0] or "")
|
|
318
|
+
conversation_id = str(row[1] or "")
|
|
319
|
+
session_id = str(row[2] or "")
|
|
320
|
+
lifecycle_reason = str(row[3] or "")
|
|
321
|
+
dispatched_at = row[5]
|
|
322
|
+
actions_json = row[6]
|
|
323
|
+
if action not in _DIARY_TRIGGERING:
|
|
324
|
+
return {"status": "processed", "event_id": event_id, "diary_required": False}
|
|
325
|
+
if not session_id:
|
|
326
|
+
return {"status": "rejected", "reason": "missing-session-id", "event_id": event_id}
|
|
327
|
+
|
|
328
|
+
existing = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
|
|
329
|
+
if existing is not None:
|
|
330
|
+
return {
|
|
331
|
+
"status": "ok",
|
|
332
|
+
"event_id": event_id,
|
|
333
|
+
"fallback_written": False,
|
|
334
|
+
"diary_confirmed": True,
|
|
335
|
+
**existing,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
payload = json.loads(row[4] or "{}")
|
|
340
|
+
if not isinstance(payload, dict):
|
|
341
|
+
payload = {}
|
|
342
|
+
except Exception:
|
|
343
|
+
payload = {}
|
|
344
|
+
|
|
345
|
+
title = str(payload.get("title") or conversation_id or event_id).strip()
|
|
346
|
+
transcript_lines = _payload_lines(payload)
|
|
347
|
+
technical_reason = str(reason or lifecycle_reason or "fallback-diary").strip()
|
|
348
|
+
diary_session_id = _preferred_diary_session_id(conn, session_id)
|
|
349
|
+
summary = (
|
|
350
|
+
"Diario automatico de emergencia generado por NEXO Desktop al cerrar "
|
|
351
|
+
f"'{title}'. No se confirmo un diario escrito por el agente vivo, asi "
|
|
352
|
+
"que se preserva el snapshot disponible para continuidad."
|
|
353
|
+
)
|
|
354
|
+
decisions = (
|
|
355
|
+
f"Accion de ciclo de vida: {action}. Evento: {event_id}. "
|
|
356
|
+
f"Motivo tecnico: {technical_reason}."
|
|
357
|
+
)
|
|
358
|
+
pending = str(payload.get("current_goal") or payload.get("last_user_message") or "").strip()
|
|
359
|
+
if not pending:
|
|
360
|
+
pending = "Revisar la conversacion al reabrir y continuar desde el snapshot preservado."
|
|
361
|
+
context_next_parts = [
|
|
362
|
+
f"conversation_id={conversation_id}",
|
|
363
|
+
f"session_id={session_id}",
|
|
364
|
+
]
|
|
365
|
+
if transcript_lines:
|
|
366
|
+
context_next_parts.append("Transcript tail:\n" + "\n".join(transcript_lines))
|
|
367
|
+
context_next = "\n".join(context_next_parts)[:8000]
|
|
368
|
+
|
|
369
|
+
from db import write_session_diary
|
|
370
|
+
|
|
371
|
+
diary = write_session_diary(
|
|
372
|
+
diary_session_id,
|
|
373
|
+
decisions=decisions,
|
|
374
|
+
summary=summary,
|
|
375
|
+
discarded="",
|
|
376
|
+
pending=pending,
|
|
377
|
+
context_next=context_next,
|
|
378
|
+
mental_state="Fallback automatico: el agente vivo no confirmo el cierre dentro del timeout.",
|
|
379
|
+
domain="nexo-desktop",
|
|
380
|
+
user_signals="Cierre/archivo de conversacion; preservar informacion antes de salir.",
|
|
381
|
+
self_critique="El cierre no debe depender exclusivamente de que el agente responda a tiempo.",
|
|
382
|
+
source=source or "desktop-lifecycle-fallback",
|
|
383
|
+
)
|
|
384
|
+
return {
|
|
385
|
+
"status": "ok",
|
|
386
|
+
"event_id": event_id,
|
|
387
|
+
"fallback_written": True,
|
|
388
|
+
"diary_confirmed": True,
|
|
389
|
+
"session_diary_id": diary.get("id"),
|
|
390
|
+
"diary_session_id": diary_session_id,
|
|
391
|
+
"source": source or "desktop-lifecycle-fallback",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
263
395
|
def _session_stop_state(conn, session_id: str) -> Dict[str, Any]:
|
|
264
396
|
"""Return whether the lifecycle session can be verified as fully stopped."""
|
|
265
397
|
raw = str(session_id or "").strip()
|
|
@@ -168,6 +168,27 @@ def handle_nexo_lifecycle_wait_for_diary(
|
|
|
168
168
|
return json.dumps(ack, ensure_ascii=False)
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def handle_nexo_lifecycle_write_fallback_diary(
|
|
172
|
+
event_id: str,
|
|
173
|
+
reason: str = "",
|
|
174
|
+
source: str = "desktop-lifecycle-fallback",
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Write emergency diary evidence for a lifecycle event."""
|
|
177
|
+
try:
|
|
178
|
+
ack = lifecycle_events.write_fallback_diary_for_lifecycle_event(
|
|
179
|
+
event_id=str(event_id or ""),
|
|
180
|
+
reason=str(reason or ""),
|
|
181
|
+
source=str(source or "desktop-lifecycle-fallback"),
|
|
182
|
+
)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
return json.dumps({
|
|
185
|
+
"status": "retryable_error",
|
|
186
|
+
"reason": f"{type(exc).__name__}: {exc}",
|
|
187
|
+
"handler_threw": True,
|
|
188
|
+
}, ensure_ascii=False)
|
|
189
|
+
return json.dumps(ack, ensure_ascii=False)
|
|
190
|
+
|
|
191
|
+
|
|
171
192
|
def handle_nexo_lifecycle_wait_for_stop(
|
|
172
193
|
event_id: str,
|
|
173
194
|
timeout_ms: int = 10_000,
|
|
@@ -231,6 +252,11 @@ TOOLS = [
|
|
|
231
252
|
"nexo_lifecycle_wait_for_diary",
|
|
232
253
|
"Wait for concrete session_diary evidence for a canonical lifecycle event before Desktop stops the session.",
|
|
233
254
|
),
|
|
255
|
+
(
|
|
256
|
+
handle_nexo_lifecycle_write_fallback_diary,
|
|
257
|
+
"nexo_lifecycle_write_fallback_diary",
|
|
258
|
+
"Write emergency session_diary evidence when Desktop cannot get a live agent-authored close diary.",
|
|
259
|
+
),
|
|
234
260
|
(
|
|
235
261
|
handle_nexo_lifecycle_wait_for_stop,
|
|
236
262
|
"nexo_lifecycle_wait_for_stop",
|
package/src/plugins/update.py
CHANGED
|
@@ -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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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": (
|
|
1055
|
+
"stderr": str(reload_result.get("error") or "reload failed")[:300],
|
|
1065
1056
|
}
|
|
1066
1057
|
)
|
|
1067
1058
|
except subprocess.TimeoutExpired:
|
package/src/runtime_power.py
CHANGED
|
@@ -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",
|
|
@@ -34,6 +34,7 @@ RESTART_ALLOWLIST = {
|
|
|
34
34
|
"nexo_lifecycle_status",
|
|
35
35
|
"nexo_lifecycle_complete_canonical",
|
|
36
36
|
"nexo_lifecycle_wait_for_diary",
|
|
37
|
+
"nexo_lifecycle_write_fallback_diary",
|
|
37
38
|
"nexo_continuity_snapshot_read",
|
|
38
39
|
"nexo_continuity_resume_bundle",
|
|
39
40
|
"nexo_continuity_audit",
|
|
@@ -1983,6 +1983,19 @@
|
|
|
1983
1983
|
},
|
|
1984
1984
|
"triggers_after": []
|
|
1985
1985
|
},
|
|
1986
|
+
"nexo_lifecycle_write_fallback_diary": {
|
|
1987
|
+
"description": "Write a Brain-side fallback session_diary for a Desktop lifecycle event when live diary injection cannot complete.",
|
|
1988
|
+
"category": "lifecycle",
|
|
1989
|
+
"source": "plugin:lifecycle_events",
|
|
1990
|
+
"requires": [],
|
|
1991
|
+
"provides": [],
|
|
1992
|
+
"internal_calls": [],
|
|
1993
|
+
"enforcement": {
|
|
1994
|
+
"level": "none",
|
|
1995
|
+
"rules": []
|
|
1996
|
+
},
|
|
1997
|
+
"triggers_after": []
|
|
1998
|
+
},
|
|
1986
1999
|
"nexo_lifecycle_stop_nexo_session": {
|
|
1987
2000
|
"description": "Best-effort explicit stop of a NEXO SID for Desktop lifecycle cleanup.",
|
|
1988
2001
|
"category": "lifecycle",
|