nexo-brain 2.6.4 → 2.6.5

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/bin/nexo-brain.js CHANGED
@@ -295,7 +295,7 @@ function getDefaultSchedule(timezone) {
295
295
  timezone: timezone || "UTC",
296
296
  auto_update: true,
297
297
  power_policy: "unset",
298
- power_policy_version: 1,
298
+ power_policy_version: 2,
299
299
  processes: {
300
300
  "cognitive-decay": { hour: 3, minute: 0 },
301
301
  "postmortem": { hour: 23, minute: 30 },
@@ -315,14 +315,19 @@ async function maybeConfigurePowerPolicy(schedule, useDefaults) {
315
315
  }
316
316
  if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
317
317
  schedule.power_policy = "unset";
318
- schedule.power_policy_version = 1;
318
+ schedule.power_policy_version = 2;
319
319
  return schedule;
320
320
  }
321
321
 
322
322
  console.log("");
323
323
  log("Optional power policy:");
324
- log("If enabled, NEXO will try to keep the machine awake for background work.");
325
- const answer = (await ask(" Keep this machine awake for background work? [y/N/later]: ")).trim().toLowerCase();
324
+ log("If enabled, NEXO will activate a platform power helper for background work.");
325
+ if (process.platform === "darwin") {
326
+ log("On macOS this uses the native caffeinate helper. Closed-lid operation depends on your setup, so wake recovery remains active.");
327
+ } else if (process.platform === "linux") {
328
+ log("On Linux this uses systemd-inhibit or caffeine when available. Closed-lid behavior depends on host power settings.");
329
+ }
330
+ const answer = (await ask(" Enable the background power helper for this machine? [y/N/later]: ")).trim().toLowerCase();
326
331
  if (answer === "y" || answer === "yes") {
327
332
  schedule.power_policy = "always_on";
328
333
  } else if (answer === "later" || answer === "l") {
@@ -330,7 +335,7 @@ async function maybeConfigurePowerPolicy(schedule, useDefaults) {
330
335
  } else {
331
336
  schedule.power_policy = "disabled";
332
337
  }
333
- schedule.power_policy_version = 1;
338
+ schedule.power_policy_version = 2;
334
339
  fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
335
340
  return schedule;
336
341
  }
@@ -1064,9 +1069,9 @@ async function main() {
1064
1069
  scanQ: " Want me to analyze your environment to get to know you deeply?\n Everything stays local, nothing leaves your machine.\n\n 1. Yes, analyze everything\n 2. No, I'll tell you over time\n > ",
1065
1070
  scanStart: "Getting to know you... this takes 1-2 minutes.",
1066
1071
  scanDone: "Done.",
1067
- caffeinateQ: " Keep Mac awake for my cognitive processes at night?\n (I consolidate memory, clean duplicates, and discover connections while you sleep)\n 1. Yes\n 2. No\n > ",
1068
- caffYes: "Nocturnal processes scheduled.",
1069
- caffNo: "Ok, I'll run them when I can.",
1072
+ caffeinateQ: " Enable the Mac power helper for my background processes?\n (Uses caffeinate. Closed-lid operation depends on your setup; wake recovery stays active.)\n 1. Yes\n 2. No\n > ",
1073
+ caffYes: "Power helper enabled.",
1074
+ caffNo: "Ok, wake recovery will cover missed windows.",
1070
1075
  dashboardQ: " Enable web dashboard at localhost:6174?\n (Always-on UI to explore memory, sessions, learnings, and system health)\n 1. Yes\n 2. No\n > ",
1071
1076
  dashYes: "Dashboard enabled.",
1072
1077
  dashNo: "Dashboard disabled. You can start it manually: nexo dashboard",
@@ -1096,9 +1101,9 @@ async function main() {
1096
1101
  scanQ: " ¿Quieres que analice tu entorno para conocerte a fondo?\n Todo queda en local, nada sale de tu máquina.\n\n 1. Sí, analiza todo\n 2. No, ya te iré contando\n > ",
1097
1102
  scanStart: "Conociéndote... esto toma 1-2 minutos.",
1098
1103
  scanDone: "Listo.",
1099
- caffeinateQ: " ¿Mantengo el Mac despierto para mis procesos cognitivos nocturnos?\n (Consolido memoria, limpio duplicados y descubro conexiones mientras duermes)\n 1. Sí\n 2. No\n > ",
1100
- caffYes: "Procesos nocturnos programados.",
1101
- caffNo: "Ok, los ejecutaré cuando pueda.",
1104
+ caffeinateQ: " ¿Activo el helper de energía del Mac para mis procesos en segundo plano?\n (Usa caffeinate. Con la tapa cerrada depende de tu setup; la recuperación al despertar sigue activa.)\n 1. Sí\n 2. No\n > ",
1105
+ caffYes: "Helper de energía activado.",
1106
+ caffNo: "Ok, la recuperación al despertar cubrirá las ventanas perdidas.",
1102
1107
  dashboardQ: " ¿Activar el dashboard web en localhost:6174?\n (UI siempre activa para explorar memoria, sesiones, learnings y salud del sistema)\n 1. Sí\n 2. No\n > ",
1103
1108
  dashYes: "Dashboard activado.",
1104
1109
  dashNo: "Dashboard desactivado. Puedes iniciarlo manualmente: nexo dashboard",
@@ -1128,9 +1133,9 @@ async function main() {
1128
1133
  scanQ: " Veux-tu que j'analyse ton environnement pour te connaître en profondeur ?\n Tout reste local.\n\n 1. Oui, analyse tout\n 2. Non, je te raconterai\n > ",
1129
1134
  scanStart: "Je fais connaissance... ça prend 1-2 minutes.",
1130
1135
  scanDone: "Terminé.",
1131
- caffeinateQ: " Garder le Mac éveillé pour mes processus nocturnes ?\n 1. Oui\n 2. Non\n > ",
1132
- caffYes: "Processus nocturnes programmés.",
1133
- caffNo: "Ok, je les exécuterai quand possible.",
1136
+ caffeinateQ: " Activer l'aide énergie du Mac pour mes processus en arrière-plan ?\n (Utilise caffeinate. Avec le capot fermé, cela dépend de votre configuration ; la reprise au réveil reste active.)\n 1. Oui\n 2. Non\n > ",
1137
+ caffYes: "Aide énergie activée.",
1138
+ caffNo: "D'accord, la reprise au réveil couvrira les fenêtres manquées.",
1134
1139
  dashboardQ: " Activer le dashboard web sur localhost:6174 ?\n (UI toujours active pour explorer mémoire, sessions et santé du système)\n 1. Oui\n 2. Non\n > ",
1135
1140
  dashYes: "Dashboard activé.",
1136
1141
  dashNo: "Dashboard désactivé. Démarrage manuel : nexo dashboard",
@@ -1160,9 +1165,9 @@ async function main() {
1160
1165
  scanQ: " Soll ich deine Umgebung analysieren um dich kennenzulernen?\n Alles bleibt lokal.\n\n 1. Ja, analysiere alles\n 2. Nein, ich erzähle dir mit der Zeit\n > ",
1161
1166
  scanStart: "Lerne dich kennen... dauert 1-2 Minuten.",
1162
1167
  scanDone: "Fertig.",
1163
- caffeinateQ: " Mac wach halten für nächtliche Prozesse?\n 1. Ja\n 2. Nein\n > ",
1164
- caffYes: "Nachtprozesse geplant.",
1165
- caffNo: "Ok, führe sie aus wenn möglich.",
1168
+ caffeinateQ: " Den Mac-Energiehelfer für meine Hintergrundprozesse aktivieren?\n (Nutzt caffeinate. Bei geschlossenem Deckel hängt das vom Setup ab; Wiederaufnahme beim Aufwachen bleibt aktiv.)\n 1. Ja\n 2. Nein\n > ",
1169
+ caffYes: "Energiehelfer aktiviert.",
1170
+ caffNo: "Okay, die Wiederaufnahme beim Aufwachen deckt verpasste Fenster ab.",
1166
1171
  dashboardQ: " Web-Dashboard auf localhost:6174 aktivieren?\n (Immer aktive UI für Speicher, Sitzungen und Systemgesundheit)\n 1. Ja\n 2. Nein\n > ",
1167
1172
  dashYes: "Dashboard aktiviert.",
1168
1173
  dashNo: "Dashboard deaktiviert. Manuell starten: nexo dashboard",
@@ -1192,9 +1197,9 @@ async function main() {
1192
1197
  scanQ: " Vuoi che analizzi il tuo ambiente per conoscerti a fondo?\n Tutto resta locale.\n\n 1. Sì, analizza tutto\n 2. No, ti racconterò col tempo\n > ",
1193
1198
  scanStart: "Ti conosco... ci vogliono 1-2 minuti.",
1194
1199
  scanDone: "Fatto.",
1195
- caffeinateQ: " Tenere il Mac sveglio per i processi notturni?\n 1. Sì\n 2. No\n > ",
1196
- caffYes: "Processi notturni programmati.",
1197
- caffNo: "Ok, li eseguirò quando possibile.",
1200
+ caffeinateQ: " Attivare l'helper energetico del Mac per i processi in background?\n (Usa caffeinate. Con il coperchio chiuso dipende dal setup; il recupero al risveglio resta attivo.)\n 1. Sì\n 2. No\n > ",
1201
+ caffYes: "Helper energetico attivato.",
1202
+ caffNo: "Ok, il recupero al risveglio coprirà le finestre perse.",
1198
1203
  dashboardQ: " Attivare la dashboard web su localhost:6174?\n (UI sempre attiva per esplorare memoria, sessioni e salute del sistema)\n 1. Sì\n 2. No\n > ",
1199
1204
  dashYes: "Dashboard attivata.",
1200
1205
  dashNo: "Dashboard disattivata. Avvio manuale: nexo dashboard",
@@ -1224,9 +1229,9 @@ async function main() {
1224
1229
  scanQ: " Queres que analise o teu ambiente para te conhecer a fundo?\n Tudo fica local.\n\n 1. Sim, analisa tudo\n 2. Não, vou-te contando\n > ",
1225
1230
  scanStart: "A conhecer-te... demora 1-2 minutos.",
1226
1231
  scanDone: "Pronto.",
1227
- caffeinateQ: " Manter o Mac acordado para processos noturnos?\n 1. Sim\n 2. Não\n > ",
1228
- caffYes: "Processos noturnos agendados.",
1229
- caffNo: "Ok, executo quando possível.",
1232
+ caffeinateQ: " Ativar o helper de energia do Mac para processos em segundo plano?\n (Usa caffeinate. Com a tampa fechada depende do teu setup; a recuperação ao despertar continua ativa.)\n 1. Sim\n 2. Não\n > ",
1233
+ caffYes: "Helper de energia ativado.",
1234
+ caffNo: "Ok, a recuperação ao despertar cobrirá janelas perdidas.",
1230
1235
  dashboardQ: " Ativar dashboard web em localhost:6174?\n (UI sempre ativa para explorar memória, sessões e saúde do sistema)\n 1. Sim\n 2. Não\n > ",
1231
1236
  dashYes: "Dashboard ativado.",
1232
1237
  dashNo: "Dashboard desativado. Iniciar manualmente: nexo dashboard",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.4",
3
+ "version": "2.6.5",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
- "description": "NEXO — Cognitive co-operator for Claude Code. Memory, emotional intelligence, overnight learning (Deep Sleep), personal scripts registry, cron management, trust scoring, managed evolution, and adaptive calibration.",
5
+ "description": "NEXO — local cognitive runtime for Claude Code. Persistent memory, overnight learning, recovery-aware crons, personal scripts, doctor diagnostics, startup preflight, and optional power helper.",
6
6
  "bin": {
7
7
  "nexo-brain": "./bin/nexo-brain.js",
8
8
  "nexo": "./bin/nexo.js"
@@ -1378,8 +1378,8 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1378
1378
  "init_db = getattr(db, 'init_db', None); "
1379
1379
  "init_db() if callable(init_db) else None; "
1380
1380
  "import script_registry; "
1381
- "sync_scripts = getattr(script_registry, 'sync_personal_scripts', None); "
1382
- "sync_scripts() if callable(sync_scripts) else None"
1381
+ "reconcile_scripts = getattr(script_registry, 'reconcile_personal_scripts', None); "
1382
+ "reconcile_scripts(dry_run=False) if callable(reconcile_scripts) else None"
1383
1383
  ),
1384
1384
  ],
1385
1385
  cwd=str(dest),
@@ -1521,6 +1521,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1521
1521
  "claude_md_update": None,
1522
1522
  "migrations": [],
1523
1523
  "power_policy": None,
1524
+ "power_message": None,
1524
1525
  "error": None,
1525
1526
  }
1526
1527
 
@@ -1529,6 +1530,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1529
1530
  choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
1530
1531
  power_result = apply_power_policy(choice.get("policy"))
1531
1532
  result["power_policy"] = choice.get("policy") or get_power_policy()
1533
+ result["power_message"] = power_result.get("message")
1532
1534
  if power_result.get("ok"):
1533
1535
  result["actions"].append(f"power:{power_result.get('action')}")
1534
1536
 
@@ -1536,7 +1538,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1536
1538
  if src_dir is not None and repo_dir is not None:
1537
1539
  try:
1538
1540
  from db import init_db
1539
- from script_registry import sync_personal_scripts
1541
+ from script_registry import reconcile_personal_scripts
1540
1542
 
1541
1543
  _run_db_migrations()
1542
1544
  result["migrations"] = run_file_migrations()
@@ -1546,7 +1548,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1546
1548
  _ensure_runtime_cli_wrapper()
1547
1549
  _ensure_runtime_cli_in_shell()
1548
1550
  init_db()
1549
- sync_personal_scripts()
1551
+ reconcile_personal_scripts(dry_run=False)
1550
1552
  result["actions"].append("db+personal-sync")
1551
1553
  except Exception as e:
1552
1554
  result["error"] = str(e)
@@ -1601,6 +1603,7 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1601
1603
  result = auto_update_check()
1602
1604
  result["entrypoint"] = entrypoint
1603
1605
  result["power_policy"] = choice.get("policy") or get_power_policy()
1606
+ result["power_message"] = power_result.get("message")
1604
1607
  if power_result.get("ok"):
1605
1608
  actions = result.setdefault("actions", [])
1606
1609
  actions.append(f"power:{power_result.get('action')}")
package/src/cli.py CHANGED
@@ -451,7 +451,7 @@ def _update(args):
451
451
  - Packaged/runtime-only install: delegate to plugins.update handle_update()
452
452
  """
453
453
  from auto_update import manual_sync_update, _resolve_sync_source
454
- from runtime_power import ensure_power_policy_choice, apply_power_policy
454
+ from runtime_power import ensure_power_policy_choice, apply_power_policy, format_power_policy_label
455
455
 
456
456
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
457
457
 
@@ -478,11 +478,14 @@ def _update(args):
478
478
  "message": result,
479
479
  "power_policy": choice.get("policy"),
480
480
  "power_action": power_result.get("action"),
481
+ "power_details": power_result.get("details"),
481
482
  }, indent=2, ensure_ascii=False))
482
483
  else:
483
484
  print(result)
484
485
  if choice.get("prompted"):
485
- print(f"Power policy: {choice.get('policy')}")
486
+ print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
487
+ if power_result.get("message"):
488
+ print(f"Power helper: {power_result.get('message')}")
486
489
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
487
490
 
488
491
  choice = ensure_power_policy_choice(interactive=interactive, reason="update")
@@ -490,6 +493,9 @@ def _update(args):
490
493
  result = manual_sync_update(interactive=interactive, allow_source_pull=True)
491
494
  result["power_policy"] = choice.get("policy")
492
495
  result["power_action"] = power_result.get("action")
496
+ result["power_details"] = power_result.get("details")
497
+ if power_result.get("message"):
498
+ result["power_message"] = power_result.get("message")
493
499
  if args.json:
494
500
  print(json.dumps(result, indent=2, ensure_ascii=False))
495
501
  else:
@@ -502,7 +508,9 @@ def _update(args):
502
508
  if result.get("pulled_source"):
503
509
  print(" Source repo: pulled latest fast-forward before sync")
504
510
  if choice.get("prompted"):
505
- print(f" Power policy: {choice.get('policy')}")
511
+ print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
512
+ if power_result.get("message"):
513
+ print(f" Power helper: {power_result.get('message')}")
506
514
  else:
507
515
  print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
508
516
  return 0 if result.get("ok") else 1
@@ -272,6 +272,48 @@ def latest_successful_runs(cron_ids: list[str], *, db_path: Path = DB_PATH) -> d
272
272
  return result
273
273
 
274
274
 
275
+ def active_started_runs(cron_ids: list[str], *, db_path: Path = DB_PATH) -> dict[str, datetime]:
276
+ """Return currently open cron_runs keyed by cron_id.
277
+
278
+ A run is considered active if it has started but not yet recorded a final
279
+ exit/ended timestamp. Catchup uses this to avoid relaunching the same cron
280
+ window while another invocation is already in flight.
281
+ """
282
+ if not cron_ids or not db_path.is_file():
283
+ return {}
284
+ conn = None
285
+ try:
286
+ conn = sqlite3.connect(str(db_path), timeout=2)
287
+ conn.row_factory = sqlite3.Row
288
+ placeholders = ",".join("?" for _ in cron_ids)
289
+ rows = conn.execute(
290
+ f"""
291
+ SELECT c1.cron_id, c1.started_at
292
+ FROM cron_runs c1
293
+ JOIN (
294
+ SELECT cron_id, MAX(id) AS max_id
295
+ FROM cron_runs
296
+ WHERE cron_id IN ({placeholders})
297
+ AND (exit_code IS NULL OR ended_at IS NULL)
298
+ GROUP BY cron_id
299
+ ) latest ON latest.max_id = c1.id
300
+ """,
301
+ tuple(cron_ids),
302
+ ).fetchall()
303
+ except Exception:
304
+ return {}
305
+ finally:
306
+ with contextlib.suppress(Exception):
307
+ conn.close()
308
+
309
+ result: dict[str, datetime] = {}
310
+ for row in rows:
311
+ parsed = _parse_timestamp(row["started_at"], assume_utc=True)
312
+ if parsed is not None:
313
+ result[row["cron_id"]] = parsed
314
+ return result
315
+
316
+
275
317
  def legacy_state_runs(*, state_file: Path = STATE_FILE) -> dict[str, datetime]:
276
318
  state = _load_json(state_file, {})
277
319
  if not isinstance(state, dict):
@@ -315,6 +357,7 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
315
357
  crons = load_enabled_crons() + load_managed_personal_crons()
316
358
  contracts = {cron["id"]: recovery_contract(cron) for cron in crons if cron.get("id")}
317
359
  successes = latest_successful_runs(list(contracts), db_path=DB_PATH)
360
+ active_runs = active_started_runs(list(contracts), db_path=DB_PATH)
318
361
  legacy = legacy_state_runs(state_file=STATE_FILE)
319
362
  candidates: list[dict] = []
320
363
 
@@ -341,8 +384,10 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
341
384
  continue
342
385
  due_at = now - timedelta(seconds=interval_seconds)
343
386
  last_success = successes.get(cron_id) or legacy.get(cron_id)
387
+ active_started = active_runs.get(cron_id)
344
388
  age_seconds = max(int((now - due_at).total_seconds()), 0)
345
- missed = last_success is None or last_success < due_at
389
+ is_inflight = active_started is not None and active_started >= due_at
390
+ missed = (last_success is None or last_success < due_at) and not is_inflight
346
391
  within_window = contract["max_catchup_age"] <= 0 or age_seconds <= contract["max_catchup_age"]
347
392
 
348
393
  candidates.append({
@@ -354,8 +399,10 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
354
399
  "schedule": schedule,
355
400
  "last_due_at": due_at,
356
401
  "last_success_at": last_success,
402
+ "active_started_at": active_started,
357
403
  "age_seconds": age_seconds,
358
404
  "missed": missed,
405
+ "inflight": is_inflight,
359
406
  "within_window": within_window,
360
407
  })
361
408
 
@@ -2,7 +2,7 @@
2
2
  # NEXO Caffeinate Guard — keeps the Mac awake so nocturnal processes run on schedule.
3
3
  # Runs as a LaunchAgent with KeepAlive=true. If killed, launchd restarts it.
4
4
  #
5
- # Uses caffeinate -s (prevent system sleep) with -i (prevent idle sleep).
6
- # The Mac screen can turn off but the system stays awake.
5
+ # Uses the native macOS caffeinate helper. Closed-lid behavior remains
6
+ # best-effort and depends on the host setup.
7
7
 
8
- exec caffeinate -s -i -w $$
8
+ exec /usr/bin/caffeinate -d -i -m -s /bin/bash -lc 'while :; do sleep 3600; done'
@@ -4,12 +4,20 @@ from __future__ import annotations
4
4
  Manages the optional "prevent sleep" helper as an explicit, persisted runtime
5
5
  preference. The policy is stored in config/schedule.json to avoid introducing a
6
6
  second user-facing config surface.
7
+
8
+ Important semantic note:
9
+ - ``always_on`` means "enable the platform power helper" for best-effort
10
+ background availability.
11
+ - It does not replace wake recovery or catchup.
12
+ - On laptops, especially with the lid closed, behavior remains platform and
13
+ setup dependent.
7
14
  """
8
15
 
9
16
  import json
10
17
  import os
11
18
  import platform
12
19
  import plistlib
20
+ import shutil
13
21
  import subprocess
14
22
  from pathlib import Path
15
23
 
@@ -20,7 +28,7 @@ CONFIG_DIR = NEXO_HOME / "config"
20
28
  SCHEDULE_FILE = CONFIG_DIR / "schedule.json"
21
29
  POWER_POLICY_KEY = "power_policy"
22
30
  POWER_POLICY_VERSION_KEY = "power_policy_version"
23
- POWER_POLICY_VERSION = 1
31
+ POWER_POLICY_VERSION = 2
24
32
  POWER_POLICY_ALWAYS_ON = "always_on"
25
33
  POWER_POLICY_DISABLED = "disabled"
26
34
  POWER_POLICY_UNSET = "unset"
@@ -31,6 +39,9 @@ VALID_POWER_POLICIES = {
31
39
  }
32
40
  LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
33
41
  LINUX_SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
42
+ MACOS_CAFFEINATE_PATH = Path("/usr/bin/caffeinate")
43
+ MACOS_CLOSED_LID_BEHAVIOR = "best_effort"
44
+ LINUX_CLOSED_LID_BEHAVIOR = "host_policy"
34
45
 
35
46
 
36
47
  def _schedule_defaults() -> dict:
@@ -80,6 +91,90 @@ def normalize_power_policy(value: str | None) -> str:
80
91
  return POWER_POLICY_UNSET
81
92
 
82
93
 
94
+ def _detect_linux_power_helper() -> tuple[str | None, str | None]:
95
+ if shutil.which("systemd-inhibit"):
96
+ return "systemd-inhibit", shutil.which("systemd-inhibit")
97
+ if shutil.which("caffeine"):
98
+ return "caffeine", shutil.which("caffeine")
99
+ return None, None
100
+
101
+
102
+ def describe_power_policy(policy: str | None = None, *, system: str | None = None) -> dict:
103
+ policy = normalize_power_policy(policy or get_power_policy())
104
+ system = system or platform.system()
105
+ base = {
106
+ "policy": policy,
107
+ "platform": system,
108
+ "helper": None,
109
+ "helper_path": None,
110
+ "helper_available": False,
111
+ "closed_lid_behavior": "n/a",
112
+ "requires_wake_recovery": True,
113
+ "summary": "",
114
+ "prompt_note": "",
115
+ }
116
+
117
+ if policy != POWER_POLICY_ALWAYS_ON:
118
+ state = "disabled" if policy == POWER_POLICY_DISABLED else "unset"
119
+ base["summary"] = f"Power helper {state}."
120
+ base["prompt_note"] = "Wake recovery and catchup remain available."
121
+ return base
122
+
123
+ if system == "Darwin":
124
+ available = MACOS_CAFFEINATE_PATH.is_file()
125
+ base.update({
126
+ "helper": "caffeinate",
127
+ "helper_path": str(MACOS_CAFFEINATE_PATH),
128
+ "helper_available": available,
129
+ "closed_lid_behavior": MACOS_CLOSED_LID_BEHAVIOR,
130
+ "summary": (
131
+ "Enable the native macOS caffeinate helper for best-effort "
132
+ "background availability."
133
+ ),
134
+ "prompt_note": (
135
+ "macOS uses the native caffeinate helper. Closed-lid operation "
136
+ "depends on your hardware/setup, so wake recovery remains active."
137
+ ),
138
+ })
139
+ return base
140
+
141
+ if system == "Linux":
142
+ helper, helper_path = _detect_linux_power_helper()
143
+ base.update({
144
+ "helper": helper,
145
+ "helper_path": helper_path,
146
+ "helper_available": bool(helper_path),
147
+ "closed_lid_behavior": LINUX_CLOSED_LID_BEHAVIOR,
148
+ "summary": (
149
+ "Enable the Linux power helper for best-effort background "
150
+ "availability."
151
+ ),
152
+ "prompt_note": (
153
+ "Linux uses systemd-inhibit or caffeine when available. "
154
+ "Closed-lid behavior depends on host power settings, so wake "
155
+ "recovery remains active."
156
+ ),
157
+ })
158
+ return base
159
+
160
+ base.update({
161
+ "summary": f"No power helper integration is available on {system}.",
162
+ "prompt_note": "Wake recovery and catchup remain available.",
163
+ })
164
+ return base
165
+
166
+
167
+ def format_power_policy_label(policy: str | None = None, *, system: str | None = None) -> str:
168
+ details = describe_power_policy(policy=policy, system=system)
169
+ policy = details["policy"]
170
+ if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Darwin":
171
+ return "always_on (macOS caffeinate, closed-lid best effort)"
172
+ if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Linux":
173
+ helper = details["helper"] or "power helper"
174
+ return f"always_on ({helper}, closed-lid depends on host policy)"
175
+ return policy
176
+
177
+
83
178
  def get_power_policy(schedule: dict | None = None) -> str:
84
179
  schedule = schedule or load_schedule_config()
85
180
  return normalize_power_policy(schedule.get(POWER_POLICY_KEY))
@@ -100,17 +195,20 @@ def set_power_policy(policy: str) -> dict:
100
195
  def prompt_for_power_policy(
101
196
  *,
102
197
  reason: str = "install",
198
+ system: str | None = None,
103
199
  input_fn=input,
104
200
  output_fn=print,
105
201
  ) -> str:
202
+ details = describe_power_policy(POWER_POLICY_ALWAYS_ON, system=system)
106
203
  prompt = (
107
- "[NEXO] Keep this machine awake for background work? "
204
+ "[NEXO] Enable the background power helper for this machine? "
108
205
  "[y]es / [n]o / [l]ater: "
109
206
  )
110
207
  output_fn(
111
208
  "[NEXO] This controls the optional prevent-sleep helper. "
112
- "It improves background availability but should remain opt-in."
209
+ "It improves background availability but remains opt-in."
113
210
  )
211
+ output_fn(f"[NEXO] {details['prompt_note']}")
114
212
  while True:
115
213
  answer = str(input_fn(prompt)).strip().lower()
116
214
  if answer in {"y", "yes"}:
@@ -134,7 +232,12 @@ def ensure_power_policy_choice(
134
232
  prompted = False
135
233
  if interactive and policy == POWER_POLICY_UNSET:
136
234
  prompted = True
137
- policy = prompt_for_power_policy(reason=reason, input_fn=input_fn, output_fn=output_fn)
235
+ policy = prompt_for_power_policy(
236
+ reason=reason,
237
+ system=platform.system(),
238
+ input_fn=input_fn,
239
+ output_fn=output_fn,
240
+ )
138
241
  schedule[POWER_POLICY_KEY] = policy
139
242
  schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
140
243
  save_schedule_config(schedule)
@@ -199,25 +302,37 @@ def apply_power_policy(policy: str | None = None) -> dict:
199
302
  system = platform.system()
200
303
  logs_dir = NEXO_HOME / "logs"
201
304
  logs_dir.mkdir(parents=True, exist_ok=True)
305
+ details = describe_power_policy(policy=policy, system=system)
202
306
 
203
307
  if system == "Darwin":
204
- return _apply_macos_power_policy(policy)
308
+ return _apply_macos_power_policy(policy, details=details)
205
309
  if system == "Linux":
206
- return _apply_linux_power_policy(policy)
310
+ return _apply_linux_power_policy(policy, details=details)
207
311
  return {
208
312
  "ok": policy != POWER_POLICY_ALWAYS_ON,
209
313
  "policy": policy,
210
314
  "platform": system,
211
315
  "action": "unsupported",
212
316
  "message": f"Unsupported platform for prevent-sleep policy: {system}",
317
+ "details": details,
213
318
  }
214
319
 
215
320
 
216
- def _apply_macos_power_policy(policy: str) -> dict:
321
+ def _apply_macos_power_policy(policy: str, *, details: dict | None = None) -> dict:
217
322
  plist_path, plist = _macos_prevent_sleep_plist()
218
323
  label = plist["Label"]
219
324
  uid = str(os.getuid())
220
325
  if policy == POWER_POLICY_ALWAYS_ON:
326
+ details = details or describe_power_policy(policy, system="Darwin")
327
+ if not details.get("helper_available"):
328
+ return {
329
+ "ok": False,
330
+ "policy": policy,
331
+ "platform": "Darwin",
332
+ "action": "missing-helper",
333
+ "message": f"Required helper not found: {details.get('helper_path') or 'caffeinate'}",
334
+ "details": details,
335
+ }
221
336
  LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
222
337
  with plist_path.open("wb") as fh:
223
338
  plistlib.dump(plist, fh)
@@ -235,6 +350,7 @@ def _apply_macos_power_policy(policy: str) -> dict:
235
350
  "action": "enabled",
236
351
  "plist_path": str(plist_path),
237
352
  "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
353
+ "details": details,
238
354
  }
239
355
 
240
356
  subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
@@ -247,12 +363,23 @@ def _apply_macos_power_policy(policy: str) -> dict:
247
363
  "platform": "Darwin",
248
364
  "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
249
365
  "plist_path": str(plist_path),
366
+ "details": details or describe_power_policy(policy, system="Darwin"),
250
367
  }
251
368
 
252
369
 
253
- def _apply_linux_power_policy(policy: str) -> dict:
370
+ def _apply_linux_power_policy(policy: str, *, details: dict | None = None) -> dict:
254
371
  service_path, service_body = _linux_prevent_sleep_service()
255
372
  if policy == POWER_POLICY_ALWAYS_ON:
373
+ details = details or describe_power_policy(policy, system="Linux")
374
+ if not details.get("helper_available"):
375
+ return {
376
+ "ok": False,
377
+ "policy": policy,
378
+ "platform": "Linux",
379
+ "action": "missing-helper",
380
+ "message": "No Linux power helper found. Install systemd-inhibit or caffeine.",
381
+ "details": details,
382
+ }
256
383
  LINUX_SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
257
384
  service_path.write_text(service_body)
258
385
  subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
@@ -269,6 +396,7 @@ def _apply_linux_power_policy(policy: str) -> dict:
269
396
  "action": "enabled",
270
397
  "service_path": str(service_path),
271
398
  "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
399
+ "details": details,
272
400
  }
273
401
 
274
402
  subprocess.run(["systemctl", "--user", "disable", "--now", "nexo-prevent-sleep.service"], capture_output=True)
@@ -281,4 +409,5 @@ def _apply_linux_power_policy(policy: str) -> dict:
281
409
  "platform": "Linux",
282
410
  "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
283
411
  "service_path": str(service_path),
412
+ "details": details or describe_power_policy(policy, system="Linux"),
284
413
  }
@@ -1,14 +1,20 @@
1
1
  #!/bin/bash
2
2
  # NEXO Prevent Sleep — keeps the machine awake so nocturnal processes run.
3
3
  #
4
- # macOS: uses caffeinate -s -i (prevent system + idle sleep)
4
+ # macOS: uses native /usr/bin/caffeinate for best-effort background availability
5
5
  # Linux: uses systemd-inhibit or caffeine if available, otherwise no-op
6
6
  #
7
7
  # Run as LaunchAgent (KeepAlive) or systemd service.
8
8
 
9
9
  case "$(uname -s)" in
10
10
  Darwin)
11
- exec caffeinate -s -i -w $$
11
+ if [[ ! -x /usr/bin/caffeinate ]]; then
12
+ echo "[NEXO] /usr/bin/caffeinate not found. macOS power helper unavailable."
13
+ exit 1
14
+ fi
15
+ # Keep the helper alive as long as this service runs. On laptops with the
16
+ # lid closed, actual behavior still depends on hardware and OS policy.
17
+ exec /usr/bin/caffeinate -d -i -m -s /bin/bash -lc 'while :; do sleep 3600; done'
12
18
  ;;
13
19
  Linux)
14
20
  if command -v systemd-inhibit &>/dev/null; then
package/src/nexo.db DELETED
Binary file