nexo-brain 2.6.3 → 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/.claude-plugin/plugin.json +2 -2
- package/README.md +143 -865
- package/bin/nexo-brain.js +28 -23
- package/package.json +2 -2
- package/src/auto_update.py +7 -4
- package/src/cli.py +11 -3
- package/src/cron_recovery.py +48 -1
- package/src/hooks/caffeinate-guard.sh +3 -3
- package/src/runtime_power.py +137 -8
- package/src/scripts/nexo-prevent-sleep.sh +8 -2
- package/src/nexo.db +0 -0
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:
|
|
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 =
|
|
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
|
|
325
|
-
|
|
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 =
|
|
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: "
|
|
1068
|
-
caffYes: "
|
|
1069
|
-
caffNo: "Ok,
|
|
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: " ¿
|
|
1100
|
-
caffYes: "
|
|
1101
|
-
caffNo: "Ok,
|
|
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: "
|
|
1132
|
-
caffYes: "
|
|
1133
|
-
caffNo: "
|
|
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
|
|
1164
|
-
caffYes: "
|
|
1165
|
-
caffNo: "
|
|
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: "
|
|
1196
|
-
caffYes: "
|
|
1197
|
-
caffNo: "Ok,
|
|
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: "
|
|
1228
|
-
caffYes: "
|
|
1229
|
-
caffNo: "Ok,
|
|
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.
|
|
3
|
+
"version": "2.6.5",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO —
|
|
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"
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
-
"
|
|
1382
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
package/src/cron_recovery.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
6
|
-
#
|
|
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 -
|
|
8
|
+
exec /usr/bin/caffeinate -d -i -m -s /bin/bash -lc 'while :; do sleep 3600; done'
|
package/src/runtime_power.py
CHANGED
|
@@ -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 =
|
|
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]
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|