nexo-brain 5.3.26 → 5.3.28

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.
Files changed (212) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/hook_guardrails.py +44 -0
  4. package/src/server.py +3 -0
  5. package/src/tools_sessions.py +6 -1
  6. package/src/dashboard/static/favicon 2.svg +0 -32
  7. package/src/dashboard/static/nexo-logo 2.png +0 -0
  8. package/src/dashboard/static/nexo-logo 2.svg +0 -40
  9. package/src/dashboard/static/style 2.css +0 -2458
  10. package/src/dashboard/templates/adaptive 2.html +0 -118
  11. package/src/dashboard/templates/artifacts 2.html +0 -133
  12. package/src/dashboard/templates/backups 2.html +0 -136
  13. package/src/dashboard/templates/base 2.html +0 -417
  14. package/src/dashboard/templates/calendar 2.html +0 -591
  15. package/src/dashboard/templates/chat 2.html +0 -356
  16. package/src/dashboard/templates/claims 2.html +0 -259
  17. package/src/dashboard/templates/cortex 2.html +0 -321
  18. package/src/dashboard/templates/credentials 2.html +0 -128
  19. package/src/dashboard/templates/crons 2.html +0 -370
  20. package/src/dashboard/templates/dashboard 2.html +0 -494
  21. package/src/dashboard/templates/dreams 2.html +0 -252
  22. package/src/dashboard/templates/email 2.html +0 -160
  23. package/src/dashboard/templates/evolution 2.html +0 -189
  24. package/src/dashboard/templates/feed 2.html +0 -249
  25. package/src/dashboard/templates/followup_health 2.html +0 -170
  26. package/src/dashboard/templates/graph 2.html +0 -201
  27. package/src/dashboard/templates/guard 2.html +0 -259
  28. package/src/dashboard/templates/inbox 2.html +0 -251
  29. package/src/dashboard/templates/memory 2.html +0 -420
  30. package/src/dashboard/templates/operations 2.html +0 -608
  31. package/src/dashboard/templates/plugins 2.html +0 -185
  32. package/src/dashboard/templates/protocol 2.html +0 -199
  33. package/src/dashboard/templates/rules 2.html +0 -246
  34. package/src/dashboard/templates/sentiment 2.html +0 -247
  35. package/src/dashboard/templates/sessions 2.html +0 -218
  36. package/src/dashboard/templates/skills 2.html +0 -329
  37. package/src/dashboard/templates/somatic 2.html +0 -73
  38. package/src/dashboard/templates/triggers 2.html +0 -133
  39. package/src/dashboard/templates/trust 2.html +0 -360
  40. package/src/db/__init__ 2.py +0 -259
  41. package/src/db/_core 2.py +0 -437
  42. package/src/db/_credentials 2.py +0 -124
  43. package/src/db/_episodic 2.py +0 -762
  44. package/src/db/_evolution 2.py +0 -54
  45. package/src/db/_fts 2.py +0 -406
  46. package/src/db/_goal_profiles 2.py +0 -376
  47. package/src/db/_hot_context 2.py +0 -660
  48. package/src/db/_outcomes 2.py +0 -800
  49. package/src/db/_personal_scripts 2.py +0 -582
  50. package/src/db/_sessions 2.py +0 -330
  51. package/src/db/_tasks 2.py +0 -91
  52. package/src/db/_watchers 2.py +0 -173
  53. package/src/doctor/formatters 2.py +0 -52
  54. package/src/doctor/models 2.py +0 -69
  55. package/src/doctor/planes 2.py +0 -87
  56. package/src/doctor/providers/__init__ 2.py +0 -1
  57. package/src/doctor/providers/deep 2.py +0 -367
  58. package/src/evolution_cycle 2.py +0 -519
  59. package/src/hooks/auto_capture 2.py +0 -208
  60. package/src/hooks/caffeinate-guard 2.sh +0 -8
  61. package/src/hooks/capture-session 2.sh +0 -21
  62. package/src/hooks/capture-tool-logs 2.sh +0 -158
  63. package/src/hooks/daily-briefing-check 2.sh +0 -33
  64. package/src/hooks/heartbeat-enforcement 2.py +0 -90
  65. package/src/hooks/heartbeat-posttool 2.sh +0 -18
  66. package/src/hooks/inbox-hook 2.sh +0 -76
  67. package/src/hooks/post-compact 2.sh +0 -152
  68. package/src/hooks/pre-compact 2.sh +0 -169
  69. package/src/hooks/protocol-guardrail 2.sh +0 -10
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
  71. package/src/hooks/session-stop 2.sh +0 -52
  72. package/src/kg_populate 2.py +0 -292
  73. package/src/maintenance 2.py +0 -53
  74. package/src/memory_backends 2.py +0 -71
  75. package/src/migrate_embeddings 2.py +0 -124
  76. package/src/nexo_sdk 2.py +0 -103
  77. package/src/observability 2.py +0 -199
  78. package/src/plugin_loader 2.py +0 -217
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/artifact_registry 2.py +0 -450
  81. package/src/plugins/backup 2.py +0 -127
  82. package/src/plugins/claims_tools 2.py +0 -119
  83. package/src/plugins/cognitive_memory 2.py +0 -609
  84. package/src/plugins/core_rules 2.py +0 -252
  85. package/src/plugins/cortex 2.py +0 -1155
  86. package/src/plugins/entities 2.py +0 -67
  87. package/src/plugins/episodic_memory 2.py +0 -560
  88. package/src/plugins/evolution 2.py +0 -167
  89. package/src/plugins/goal_engine 2.py +0 -142
  90. package/src/plugins/guard 2.py +0 -862
  91. package/src/plugins/impact 2.py +0 -29
  92. package/src/plugins/knowledge_graph_tools 2.py +0 -137
  93. package/src/plugins/media_memory_tools 2.py +0 -98
  94. package/src/plugins/memory_export 2.py +0 -196
  95. package/src/plugins/outcomes 2.py +0 -130
  96. package/src/plugins/personal_scripts 2.py +0 -117
  97. package/src/plugins/preferences 2.py +0 -47
  98. package/src/plugins/protocol 2.py +0 -1449
  99. package/src/plugins/simple_api 2.py +0 -106
  100. package/src/plugins/skills 2.py +0 -341
  101. package/src/plugins/state_watchers 2.py +0 -79
  102. package/src/plugins/update 2.py +0 -986
  103. package/src/plugins/user_state_tools 2.py +0 -43
  104. package/src/plugins/workflow 2.py +0 -588
  105. package/src/protocol_settings 2.py +0 -59
  106. package/src/public_contribution 2.py +0 -466
  107. package/src/public_evolution_queue 2.py +0 -241
  108. package/src/requirements 2.txt +0 -14
  109. package/src/retroactive_learnings 2.py +0 -373
  110. package/src/rules/__init__ 2.py +0 -0
  111. package/src/rules/core-rules 2.json +0 -331
  112. package/src/rules/migrate 2.py +0 -207
  113. package/src/runtime_power 2.py +0 -874
  114. package/src/script_registry 2.py +0 -1559
  115. package/src/scripts/check-context 2.py +0 -272
  116. package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
  117. package/src/scripts/deep-sleep/collect 2.py +0 -928
  118. package/src/scripts/deep-sleep/extract 2.py +0 -330
  119. package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
  120. package/src/scripts/deep-sleep/synthesize 2.py +0 -312
  121. package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
  122. package/src/scripts/nexo-agent-run 2.py +0 -75
  123. package/src/scripts/nexo-auto-update 2.py +0 -6
  124. package/src/scripts/nexo-backup 2.sh +0 -25
  125. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  126. package/src/scripts/nexo-catchup 2.py +0 -300
  127. package/src/scripts/nexo-cognitive-decay 2.py +0 -257
  128. package/src/scripts/nexo-cortex-cycle 2.py +0 -293
  129. package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
  130. package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
  131. package/src/scripts/nexo-dashboard 2.sh +0 -29
  132. package/src/scripts/nexo-deep-sleep 2.sh +0 -86
  133. package/src/scripts/nexo-evolution-run 2.py +0 -1664
  134. package/src/scripts/nexo-followup-hygiene 2.py +0 -139
  135. package/src/scripts/nexo-hook-record 2.py +0 -42
  136. package/src/scripts/nexo-immune 2.py +0 -936
  137. package/src/scripts/nexo-impact-scorer 2.py +0 -117
  138. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  139. package/src/scripts/nexo-install 2.py +0 -6
  140. package/src/scripts/nexo-learning-housekeep 2.py +0 -401
  141. package/src/scripts/nexo-learning-validator 2.py +0 -266
  142. package/src/scripts/nexo-migrate 2.py +0 -260
  143. package/src/scripts/nexo-outcome-checker 2.py +0 -127
  144. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
  145. package/src/scripts/nexo-pre-commit 2.py +0 -120
  146. package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
  147. package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
  148. package/src/scripts/nexo-reflection 2.py +0 -256
  149. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  150. package/src/scripts/nexo-sleep 2.py +0 -631
  151. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  152. package/src/scripts/nexo-sync-clients 2.py +0 -16
  153. package/src/scripts/nexo-synthesis 2.py +0 -475
  154. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  155. package/src/scripts/nexo-update 2.sh +0 -306
  156. package/src/scripts/nexo-watchdog 2.sh +0 -1207
  157. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  158. package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
  159. package/src/server 2.py +0 -1296
  160. package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
  161. package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
  162. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
  163. package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
  164. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
  165. package/src/skills/run-release-final-audit/guide 2.md +0 -16
  166. package/src/skills/run-release-final-audit/script 2.py +0 -259
  167. package/src/skills/run-release-final-audit/skill 2.json +0 -77
  168. package/src/skills/run-runtime-doctor/guide 2.md +0 -12
  169. package/src/skills/run-runtime-doctor/script 2.py +0 -21
  170. package/src/skills/run-runtime-doctor/skill 2.json +0 -25
  171. package/src/skills_runtime 2.py +0 -932
  172. package/src/state_watchers_runtime 2.py +0 -475
  173. package/src/storage_router 2.py +0 -32
  174. package/src/system_catalog 2.py +0 -786
  175. package/src/tools_coordination 2.py +0 -103
  176. package/src/tools_credentials 2.py +0 -68
  177. package/src/tools_drive 2.py +0 -487
  178. package/src/tools_hot_context 2.py +0 -163
  179. package/src/tools_learnings 2.py +0 -612
  180. package/src/tools_menu 2.py +0 -229
  181. package/src/tools_reminders 2.py +0 -88
  182. package/src/tools_reminders_crud 2.py +0 -363
  183. package/src/tools_sessions 2.py +0 -1054
  184. package/src/tools_system_catalog 2.py +0 -19
  185. package/src/tools_task_history 2.py +0 -57
  186. package/src/tools_transcripts 2.py +0 -98
  187. package/src/transcript_utils 2.py +0 -412
  188. package/src/user_context 2.py +0 -46
  189. package/src/user_data_portability 2.py +0 -328
  190. package/src/user_state_model 2.py +0 -170
  191. package/templates/CLAUDE.md 2.template +0 -108
  192. package/templates/CODEX.AGENTS.md 2.template +0 -66
  193. package/templates/launchagents/README 2.md +0 -132
  194. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
  195. package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
  196. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
  197. package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
  198. package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
  199. package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
  200. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
  201. package/templates/launchagents/com.nexo.immune 2.plist +0 -41
  202. package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
  203. package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
  204. package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
  205. package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
  206. package/templates/nexo_helper 2.py +0 -301
  207. package/templates/openclaw 2.json +0 -13
  208. package/templates/plugin-template 2.py +0 -40
  209. package/templates/script-template 2.py +0 -59
  210. package/templates/script-template 2.sh +0 -13
  211. package/templates/skill-script-template 2.py +0 -48
  212. package/templates/skill-template 2.md +0 -33
@@ -1,874 +0,0 @@
1
- from __future__ import annotations
2
- """Runtime power policy helpers.
3
-
4
- Manages the optional "prevent sleep" helper as an explicit, persisted runtime
5
- preference. The policy is stored in config/schedule.json to avoid introducing a
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.
14
- """
15
-
16
- import json
17
- import os
18
- import platform
19
- import plistlib
20
- import shutil
21
- import subprocess
22
- import sys
23
- from pathlib import Path
24
-
25
-
26
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
27
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
28
- CONFIG_DIR = NEXO_HOME / "config"
29
- SCHEDULE_FILE = CONFIG_DIR / "schedule.json"
30
- POWER_POLICY_KEY = "power_policy"
31
- POWER_POLICY_VERSION_KEY = "power_policy_version"
32
- POWER_POLICY_VERSION = 2
33
- POWER_POLICY_ALWAYS_ON = "always_on"
34
- POWER_POLICY_DISABLED = "disabled"
35
- POWER_POLICY_UNSET = "unset"
36
- VALID_POWER_POLICIES = {
37
- POWER_POLICY_ALWAYS_ON,
38
- POWER_POLICY_DISABLED,
39
- POWER_POLICY_UNSET,
40
- }
41
- FULL_DISK_ACCESS_STATUS_KEY = "full_disk_access_status"
42
- FULL_DISK_ACCESS_STATUS_VERSION_KEY = "full_disk_access_status_version"
43
- FULL_DISK_ACCESS_REASONS_KEY = "full_disk_access_reasons"
44
- FULL_DISK_ACCESS_STATUS_VERSION = 1
45
- FULL_DISK_ACCESS_UNSET = "unset"
46
- FULL_DISK_ACCESS_GRANTED = "granted"
47
- FULL_DISK_ACCESS_DECLINED = "declined"
48
- FULL_DISK_ACCESS_LATER = "later"
49
- VALID_FULL_DISK_ACCESS_STATUSES = {
50
- FULL_DISK_ACCESS_UNSET,
51
- FULL_DISK_ACCESS_GRANTED,
52
- FULL_DISK_ACCESS_DECLINED,
53
- FULL_DISK_ACCESS_LATER,
54
- }
55
- LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
56
- LINUX_SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
57
- MACOS_CAFFEINATE_PATH = Path("/usr/bin/caffeinate")
58
- MACOS_CLOSED_LID_BEHAVIOR = "best_effort"
59
- LINUX_CLOSED_LID_BEHAVIOR = "host_policy"
60
- MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
61
- MACOS_FDA_PROBE_PATHS = (
62
- Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db",
63
- Path.home() / "Library" / "Mail",
64
- Path.home() / "Library" / "Messages",
65
- Path.home() / "Library" / "Safari",
66
- Path.home() / "Library" / "Application Support" / "AddressBook",
67
- )
68
- DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
69
- DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
70
- # Codex defaults mirror the user's primary model — no hardcoded third-party models.
71
- DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
72
- DEFAULT_CODEX_REASONING_EFFORT = ""
73
-
74
-
75
- def resolve_launchagent_path() -> str:
76
- """Build a PATH string for LaunchAgent plists that includes nvm node if present."""
77
- home = Path.home()
78
- parts = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
79
- str(home / ".local/bin"), str(home / ".nexo/bin")]
80
- # Detect nvm node
81
- nvm_dir = home / ".nvm/versions/node"
82
- if nvm_dir.is_dir():
83
- versions = sorted(nvm_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
84
- for v in versions:
85
- node_bin = v / "bin"
86
- if (node_bin / "node").exists():
87
- parts.insert(0, str(node_bin))
88
- break
89
- return ":".join(parts)
90
-
91
-
92
- def _schedule_defaults() -> dict:
93
- return {
94
- "timezone": "UTC",
95
- "auto_update": True,
96
- "interactive_clients": {
97
- "claude_code": True,
98
- "codex": False,
99
- "claude_desktop": False,
100
- },
101
- "default_terminal_client": "claude_code",
102
- "automation_enabled": True,
103
- "automation_backend": "claude_code",
104
- "client_runtime_profiles": {
105
- "claude_code": {
106
- "model": DEFAULT_CLAUDE_CODE_MODEL,
107
- "reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
108
- },
109
- "codex": {
110
- "model": DEFAULT_CODEX_MODEL,
111
- "reasoning_effort": DEFAULT_CODEX_REASONING_EFFORT,
112
- },
113
- },
114
- "client_install_preferences": {
115
- "claude_code": "ask",
116
- "codex": "ask",
117
- "claude_desktop": "manual",
118
- },
119
- POWER_POLICY_KEY: POWER_POLICY_UNSET,
120
- POWER_POLICY_VERSION_KEY: POWER_POLICY_VERSION,
121
- FULL_DISK_ACCESS_STATUS_KEY: FULL_DISK_ACCESS_UNSET,
122
- FULL_DISK_ACCESS_STATUS_VERSION_KEY: FULL_DISK_ACCESS_STATUS_VERSION,
123
- FULL_DISK_ACCESS_REASONS_KEY: [],
124
- "processes": {},
125
- }
126
-
127
-
128
- def load_schedule_config() -> dict:
129
- if not SCHEDULE_FILE.is_file():
130
- return _schedule_defaults()
131
- try:
132
- data = json.loads(SCHEDULE_FILE.read_text())
133
- except Exception:
134
- return _schedule_defaults()
135
- if not isinstance(data, dict):
136
- return _schedule_defaults()
137
- merged = _schedule_defaults()
138
- merged.update(data)
139
- merged.setdefault("processes", {})
140
- return merged
141
-
142
-
143
- def save_schedule_config(schedule: dict) -> Path:
144
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
145
- payload = dict(_schedule_defaults())
146
- payload.update(schedule or {})
147
- payload.setdefault("processes", {})
148
- payload[POWER_POLICY_KEY] = normalize_power_policy(payload.get(POWER_POLICY_KEY))
149
- payload[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
150
- payload[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(
151
- payload.get(FULL_DISK_ACCESS_STATUS_KEY)
152
- )
153
- payload[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
154
- payload[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(
155
- payload.get(FULL_DISK_ACCESS_REASONS_KEY)
156
- )
157
- SCHEDULE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
158
- return SCHEDULE_FILE
159
-
160
-
161
- def normalize_power_policy(value: str | None) -> str:
162
- candidate = str(value or "").strip().lower()
163
- if candidate in {"enabled", "yes", "on", "true", "1"}:
164
- return POWER_POLICY_ALWAYS_ON
165
- if candidate in {"disabled", "no", "off", "false", "0"}:
166
- return POWER_POLICY_DISABLED
167
- if candidate in VALID_POWER_POLICIES:
168
- return candidate
169
- return POWER_POLICY_UNSET
170
-
171
-
172
- def _detect_linux_power_helper() -> tuple[str | None, str | None]:
173
- if shutil.which("systemd-inhibit"):
174
- return "systemd-inhibit", shutil.which("systemd-inhibit")
175
- if shutil.which("caffeine"):
176
- return "caffeine", shutil.which("caffeine")
177
- return None, None
178
-
179
-
180
- def describe_power_policy(policy: str | None = None, *, system: str | None = None) -> dict:
181
- policy = normalize_power_policy(policy or get_power_policy())
182
- system = system or platform.system()
183
- base = {
184
- "policy": policy,
185
- "platform": system,
186
- "helper": None,
187
- "helper_path": None,
188
- "helper_available": False,
189
- "closed_lid_behavior": "n/a",
190
- "requires_wake_recovery": True,
191
- "summary": "",
192
- "prompt_note": "",
193
- }
194
-
195
- if policy != POWER_POLICY_ALWAYS_ON:
196
- state = "disabled" if policy == POWER_POLICY_DISABLED else "unset"
197
- base["summary"] = f"Power helper {state}."
198
- base["prompt_note"] = "Wake recovery and catchup remain available."
199
- return base
200
-
201
- if system == "Darwin":
202
- available = MACOS_CAFFEINATE_PATH.is_file()
203
- base.update({
204
- "helper": "caffeinate",
205
- "helper_path": str(MACOS_CAFFEINATE_PATH),
206
- "helper_available": available,
207
- "closed_lid_behavior": MACOS_CLOSED_LID_BEHAVIOR,
208
- "summary": (
209
- "Enable the native macOS caffeinate helper for best-effort "
210
- "background availability."
211
- ),
212
- "prompt_note": (
213
- "macOS uses the native caffeinate helper. Closed-lid operation "
214
- "depends on your hardware/setup, so wake recovery remains active."
215
- ),
216
- })
217
- return base
218
-
219
- if system == "Linux":
220
- helper, helper_path = _detect_linux_power_helper()
221
- base.update({
222
- "helper": helper,
223
- "helper_path": helper_path,
224
- "helper_available": bool(helper_path),
225
- "closed_lid_behavior": LINUX_CLOSED_LID_BEHAVIOR,
226
- "summary": (
227
- "Enable the Linux power helper for best-effort background "
228
- "availability."
229
- ),
230
- "prompt_note": (
231
- "Linux uses systemd-inhibit or caffeine when available. "
232
- "Closed-lid behavior depends on host power settings, so wake "
233
- "recovery remains active."
234
- ),
235
- })
236
- return base
237
-
238
- base.update({
239
- "summary": f"No power helper integration is available on {system}.",
240
- "prompt_note": "Wake recovery and catchup remain available.",
241
- })
242
- return base
243
-
244
-
245
- def _protected_macos_roots(home: Path | None = None) -> tuple[Path, ...]:
246
- home = home or Path.home()
247
- return (
248
- home / "Documents",
249
- home / "Desktop",
250
- home / "Downloads",
251
- home / "Library" / "Mobile Documents",
252
- )
253
-
254
-
255
- def _is_protected_macos_path(candidate: str | os.PathLike[str] | Path | None) -> bool:
256
- if not candidate:
257
- return False
258
- if platform.system() != "Darwin":
259
- return False
260
- resolved = Path(candidate).expanduser().resolve(strict=False)
261
- return any(resolved == root or root in resolved.parents for root in _protected_macos_roots())
262
-
263
-
264
- def normalize_full_disk_access_status(value: str | None) -> str:
265
- candidate = str(value or "").strip().lower()
266
- if candidate in {"enabled", "yes", "approved", "ok", "true", "1"}:
267
- return FULL_DISK_ACCESS_GRANTED
268
- if candidate in {"no", "disabled", "off", "false", "0"}:
269
- return FULL_DISK_ACCESS_DECLINED
270
- if candidate in VALID_FULL_DISK_ACCESS_STATUSES:
271
- return candidate
272
- return FULL_DISK_ACCESS_UNSET
273
-
274
-
275
- def normalize_full_disk_access_reasons(value) -> list[str]:
276
- if not value:
277
- return []
278
- if isinstance(value, str):
279
- value = [value]
280
- if not isinstance(value, (list, tuple, set)):
281
- return []
282
- reasons: list[str] = []
283
- for item in value:
284
- text = str(item or "").strip()
285
- if text and text not in reasons:
286
- reasons.append(text)
287
- return reasons
288
-
289
-
290
- def get_full_disk_access_status(schedule: dict | None = None) -> str:
291
- schedule = schedule or load_schedule_config()
292
- return normalize_full_disk_access_status(schedule.get(FULL_DISK_ACCESS_STATUS_KEY))
293
-
294
-
295
- def format_full_disk_access_label(status: str | None = None, *, system: str | None = None) -> str:
296
- status = normalize_full_disk_access_status(status or get_full_disk_access_status())
297
- system = system or platform.system()
298
- if system != "Darwin":
299
- return "not_applicable"
300
- if status == FULL_DISK_ACCESS_GRANTED:
301
- return "granted"
302
- if status == FULL_DISK_ACCESS_DECLINED:
303
- return "declined"
304
- if status == FULL_DISK_ACCESS_LATER:
305
- return "later"
306
- return "unset"
307
-
308
-
309
- def _tail_has_permission_denial(log_file: Path) -> bool:
310
- if not log_file.is_file():
311
- return False
312
- try:
313
- with log_file.open("rb") as fh:
314
- fh.seek(0, os.SEEK_END)
315
- size = fh.tell()
316
- fh.seek(max(size - 4096, 0))
317
- tail = fh.read().decode("utf-8", errors="ignore")
318
- return "Operation not permitted" in tail
319
- except Exception:
320
- return False
321
-
322
-
323
- def detect_full_disk_access_reasons(*, system: str | None = None) -> list[str]:
324
- system = system or platform.system()
325
- if system != "Darwin":
326
- return []
327
-
328
- reasons: list[str] = []
329
- if _is_protected_macos_path(NEXO_HOME):
330
- reasons.append(
331
- f"NEXO_HOME is inside a protected macOS folder: {NEXO_HOME}"
332
- )
333
-
334
- logs_dir = NEXO_HOME / "logs"
335
- if logs_dir.is_dir():
336
- for log_file in sorted(logs_dir.glob("*-stderr.log")):
337
- if _tail_has_permission_denial(log_file):
338
- reasons.append(
339
- f"Recent background job stderr hit 'Operation not permitted' ({log_file.name})"
340
- )
341
- break
342
- return reasons
343
-
344
-
345
- def _runtime_python_candidates() -> list[str]:
346
- candidates: list[str] = []
347
- runtime_python = NEXO_HOME / ".venv" / "bin" / "python3"
348
- if runtime_python.is_file():
349
- candidates.append(str(runtime_python))
350
- if sys.executable:
351
- candidates.append(sys.executable)
352
- python3_path = shutil.which("python3")
353
- if python3_path:
354
- candidates.append(python3_path)
355
- seen: set[str] = set()
356
- ordered: list[str] = []
357
- for item in candidates:
358
- if item and item not in seen:
359
- seen.add(item)
360
- ordered.append(item)
361
- return ordered
362
-
363
-
364
- def full_disk_access_targets() -> list[str]:
365
- targets = ["/bin/bash", *(_runtime_python_candidates())]
366
- seen: set[str] = set()
367
- ordered: list[str] = []
368
- for item in targets:
369
- if item and item not in seen:
370
- seen.add(item)
371
- ordered.append(item)
372
- return ordered
373
-
374
-
375
- def open_full_disk_access_settings() -> dict:
376
- if platform.system() != "Darwin":
377
- return {"ok": False, "opened": False, "message": "Full Disk Access setup is macOS-only."}
378
- try:
379
- result = subprocess.run(
380
- ["open", MACOS_FDA_SETTINGS_URL],
381
- capture_output=True,
382
- text=True,
383
- timeout=5,
384
- )
385
- ok = result.returncode == 0
386
- return {
387
- "ok": ok,
388
- "opened": ok,
389
- "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
390
- }
391
- except Exception as e:
392
- return {"ok": False, "opened": False, "message": str(e)}
393
-
394
-
395
- def _probe_candidates() -> list[Path]:
396
- candidates: list[Path] = []
397
- for path_candidate in MACOS_FDA_PROBE_PATHS:
398
- expanded = path_candidate.expanduser()
399
- if expanded.exists():
400
- candidates.append(expanded)
401
- if _is_protected_macos_path(NEXO_HOME):
402
- candidates.append(NEXO_HOME)
403
- return candidates
404
-
405
-
406
- def probe_full_disk_access() -> dict:
407
- if platform.system() != "Darwin":
408
- return {"checked": False, "granted": None, "probe_path": None, "message": "macOS-only"}
409
-
410
- candidates = _probe_candidates()
411
- if not candidates:
412
- return {
413
- "checked": False,
414
- "granted": None,
415
- "probe_path": None,
416
- "message": "No local probe path available for verification.",
417
- }
418
-
419
- script = 'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi'
420
- last_error = ""
421
- for candidate in candidates:
422
- try:
423
- result = subprocess.run(
424
- ["/bin/bash", "-lc", script, "_", str(candidate)],
425
- capture_output=True,
426
- text=True,
427
- timeout=5,
428
- )
429
- except Exception as e:
430
- last_error = str(e)
431
- continue
432
- if result.returncode == 0:
433
- return {
434
- "checked": True,
435
- "granted": True,
436
- "probe_path": str(candidate),
437
- "message": "",
438
- }
439
- last_error = result.stderr.strip() or result.stdout.strip()
440
- return {
441
- "checked": True,
442
- "granted": False,
443
- "probe_path": str(candidates[0]),
444
- "message": last_error,
445
- }
446
-
447
-
448
- def prompt_for_full_disk_access(
449
- *,
450
- reason: str = "install",
451
- reasons: list[str] | None = None,
452
- input_fn=input,
453
- output_fn=print,
454
- open_fn=open_full_disk_access_settings,
455
- probe_fn=probe_full_disk_access,
456
- ) -> dict:
457
- reasons = normalize_full_disk_access_reasons(reasons)
458
- output_fn(
459
- "[NEXO] Some macOS background automations may need Full Disk Access. "
460
- "macOS does not allow granting it automatically."
461
- )
462
- if reasons:
463
- output_fn("[NEXO] Reason(s) detected:")
464
- for item in reasons:
465
- output_fn(f"[NEXO] - {item}")
466
- output_fn("[NEXO] If you continue, NEXO will open the correct System Settings screen.")
467
- output_fn("[NEXO] Add your terminal app and, if needed for background jobs, these binaries:")
468
- for target in full_disk_access_targets():
469
- output_fn(f"[NEXO] - {target}")
470
-
471
- prompt = "[NEXO] Open Full Disk Access setup now? [y]es / [n]o / [l]ater: "
472
- while True:
473
- answer = str(input_fn(prompt)).strip().lower()
474
- if answer in {"y", "yes"}:
475
- open_result = open_fn()
476
- if open_result.get("opened"):
477
- output_fn("[NEXO] System Settings opened at Privacy & Security → Full Disk Access.")
478
- elif open_result.get("message"):
479
- output_fn(f"[NEXO] Could not open System Settings automatically: {open_result['message']}")
480
- output_fn("[NEXO] Grant the permission, then press Enter to verify.")
481
- follow_up = str(
482
- input_fn("[NEXO] Press Enter after granting it, or type later to skip for now: ")
483
- ).strip().lower()
484
- if follow_up in {"later", "l"}:
485
- return {
486
- "status": FULL_DISK_ACCESS_LATER,
487
- "settings_opened": bool(open_result.get("opened")),
488
- "verified": False,
489
- "message": "Full Disk Access setup deferred for later.",
490
- }
491
- probe = probe_fn()
492
- if probe.get("granted") is True:
493
- return {
494
- "status": FULL_DISK_ACCESS_GRANTED,
495
- "settings_opened": bool(open_result.get("opened")),
496
- "verified": True,
497
- "message": f"Full Disk Access verified via {probe.get('probe_path')}.",
498
- }
499
- return {
500
- "status": FULL_DISK_ACCESS_LATER,
501
- "settings_opened": bool(open_result.get("opened")),
502
- "verified": False,
503
- "message": (
504
- "Could not verify Full Disk Access yet. NEXO will remind you later if "
505
- "background jobs still hit TCC."
506
- ),
507
- }
508
- if answer in {"n", "no"}:
509
- return {
510
- "status": FULL_DISK_ACCESS_DECLINED,
511
- "settings_opened": False,
512
- "verified": False,
513
- "message": "Full Disk Access was declined.",
514
- }
515
- if answer in {"l", "later", ""}:
516
- return {
517
- "status": FULL_DISK_ACCESS_LATER,
518
- "settings_opened": False,
519
- "verified": False,
520
- "message": "Full Disk Access setup deferred for later.",
521
- }
522
- output_fn("[NEXO] Reply with yes, no, or later.")
523
-
524
-
525
- def format_power_policy_label(policy: str | None = None, *, system: str | None = None) -> str:
526
- details = describe_power_policy(policy=policy, system=system)
527
- policy = details["policy"]
528
- if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Darwin":
529
- return "always_on (macOS caffeinate, closed-lid best effort)"
530
- if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Linux":
531
- helper = details["helper"] or "power helper"
532
- return f"always_on ({helper}, closed-lid depends on host policy)"
533
- return policy
534
-
535
-
536
- def get_power_policy(schedule: dict | None = None) -> str:
537
- schedule = schedule or load_schedule_config()
538
- return normalize_power_policy(schedule.get(POWER_POLICY_KEY))
539
-
540
-
541
- def is_power_policy_configured(schedule: dict | None = None) -> bool:
542
- return get_power_policy(schedule) != POWER_POLICY_UNSET
543
-
544
-
545
- def set_power_policy(policy: str) -> dict:
546
- schedule = load_schedule_config()
547
- schedule[POWER_POLICY_KEY] = normalize_power_policy(policy)
548
- schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
549
- save_schedule_config(schedule)
550
- return schedule
551
-
552
-
553
- def set_full_disk_access_status(status: str, *, reasons: list[str] | None = None) -> dict:
554
- schedule = load_schedule_config()
555
- schedule[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(status)
556
- schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
557
- if reasons is not None:
558
- schedule[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(reasons)
559
- save_schedule_config(schedule)
560
- return schedule
561
-
562
-
563
- def prompt_for_power_policy(
564
- *,
565
- reason: str = "install",
566
- system: str | None = None,
567
- input_fn=input,
568
- output_fn=print,
569
- ) -> str:
570
- details = describe_power_policy(POWER_POLICY_ALWAYS_ON, system=system)
571
- prompt = (
572
- "[NEXO] Enable the background power helper for this machine? "
573
- "[y]es / [n]o / [l]ater: "
574
- )
575
- output_fn(
576
- "[NEXO] This controls the optional prevent-sleep helper. "
577
- "It improves background availability but remains opt-in."
578
- )
579
- output_fn(f"[NEXO] {details['prompt_note']}")
580
- while True:
581
- answer = str(input_fn(prompt)).strip().lower()
582
- if answer in {"y", "yes"}:
583
- return POWER_POLICY_ALWAYS_ON
584
- if answer in {"n", "no"}:
585
- return POWER_POLICY_DISABLED
586
- if answer in {"l", "later", ""}:
587
- return POWER_POLICY_UNSET
588
- output_fn("[NEXO] Reply with yes, no, or later.")
589
-
590
-
591
- def ensure_power_policy_choice(
592
- *,
593
- interactive: bool,
594
- reason: str = "update",
595
- input_fn=input,
596
- output_fn=print,
597
- ) -> dict:
598
- schedule = load_schedule_config()
599
- policy = get_power_policy(schedule)
600
- prompted = False
601
- if interactive and policy == POWER_POLICY_UNSET:
602
- prompted = True
603
- policy = prompt_for_power_policy(
604
- reason=reason,
605
- system=platform.system(),
606
- input_fn=input_fn,
607
- output_fn=output_fn,
608
- )
609
- schedule[POWER_POLICY_KEY] = policy
610
- schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
611
- save_schedule_config(schedule)
612
- return {
613
- "policy": policy,
614
- "prompted": prompted,
615
- "schedule_file": str(SCHEDULE_FILE),
616
- }
617
-
618
-
619
- def ensure_full_disk_access_choice(
620
- *,
621
- interactive: bool,
622
- reason: str = "update",
623
- input_fn=input,
624
- output_fn=print,
625
- open_fn=open_full_disk_access_settings,
626
- probe_fn=probe_full_disk_access,
627
- ) -> dict:
628
- schedule = load_schedule_config()
629
- system = platform.system()
630
- status = get_full_disk_access_status(schedule)
631
- reasons = detect_full_disk_access_reasons(system=system)
632
- prompted = False
633
- verified = False
634
- settings_opened = False
635
- message = ""
636
-
637
- if system != "Darwin":
638
- return {
639
- "status": status,
640
- "prompted": False,
641
- "verified": False,
642
- "settings_opened": False,
643
- "reasons": [],
644
- "schedule_file": str(SCHEDULE_FILE),
645
- "message": "",
646
- "relevant": False,
647
- }
648
-
649
- schedule[FULL_DISK_ACCESS_REASONS_KEY] = reasons
650
- schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
651
-
652
- if not reasons:
653
- save_schedule_config(schedule)
654
- return {
655
- "status": status,
656
- "prompted": False,
657
- "verified": False,
658
- "settings_opened": False,
659
- "reasons": [],
660
- "schedule_file": str(SCHEDULE_FILE),
661
- "message": "",
662
- "relevant": False,
663
- }
664
-
665
- if status == FULL_DISK_ACCESS_GRANTED:
666
- probe = probe_fn()
667
- if probe.get("granted") is True:
668
- verified = True
669
- message = f"Full Disk Access verified via {probe.get('probe_path')}."
670
- else:
671
- status = FULL_DISK_ACCESS_LATER
672
- message = (
673
- "Full Disk Access was configured previously but could not be verified. "
674
- "NEXO will remind you again on the next interactive update."
675
- )
676
-
677
- elif interactive and status in {FULL_DISK_ACCESS_UNSET, FULL_DISK_ACCESS_LATER}:
678
- prompted = True
679
- prompt_result = prompt_for_full_disk_access(
680
- reason=reason,
681
- reasons=reasons,
682
- input_fn=input_fn,
683
- output_fn=output_fn,
684
- open_fn=open_fn,
685
- probe_fn=probe_fn,
686
- )
687
- status = normalize_full_disk_access_status(prompt_result.get("status"))
688
- verified = bool(prompt_result.get("verified"))
689
- settings_opened = bool(prompt_result.get("settings_opened"))
690
- message = str(prompt_result.get("message") or "")
691
-
692
- elif status == FULL_DISK_ACCESS_DECLINED:
693
- message = (
694
- "Full Disk Access remains declined. Background jobs that touch protected "
695
- "macOS folders may fail."
696
- )
697
-
698
- schedule[FULL_DISK_ACCESS_STATUS_KEY] = status
699
- save_schedule_config(schedule)
700
- return {
701
- "status": status,
702
- "prompted": prompted,
703
- "verified": verified,
704
- "settings_opened": settings_opened,
705
- "reasons": reasons,
706
- "schedule_file": str(SCHEDULE_FILE),
707
- "message": message,
708
- "relevant": True,
709
- }
710
-
711
-
712
- def _prevent_sleep_script_path() -> Path:
713
- runtime_script = NEXO_HOME / "scripts" / "nexo-prevent-sleep.sh"
714
- if runtime_script.is_file():
715
- return runtime_script
716
- source_script = NEXO_CODE / "scripts" / "nexo-prevent-sleep.sh"
717
- return source_script
718
-
719
-
720
- def _macos_prevent_sleep_plist() -> tuple[Path, dict]:
721
- script_path = _prevent_sleep_script_path()
722
- plist_path = LAUNCH_AGENTS_DIR / "com.nexo.prevent-sleep.plist"
723
- plist = {
724
- "Label": "com.nexo.prevent-sleep",
725
- "ProgramArguments": ["/bin/bash", str(script_path)],
726
- "RunAtLoad": True,
727
- "KeepAlive": True,
728
- "StandardOutPath": str(NEXO_HOME / "logs" / "prevent-sleep-stdout.log"),
729
- "StandardErrorPath": str(NEXO_HOME / "logs" / "prevent-sleep-stderr.log"),
730
- "EnvironmentVariables": {
731
- "HOME": str(Path.home()),
732
- "NEXO_HOME": str(NEXO_HOME),
733
- "NEXO_CODE": str(NEXO_HOME),
734
- "PATH": resolve_launchagent_path(),
735
- },
736
- }
737
- return plist_path, plist
738
-
739
-
740
- def _linux_prevent_sleep_service() -> tuple[Path, str]:
741
- script_path = _prevent_sleep_script_path()
742
- service_path = LINUX_SYSTEMD_USER_DIR / "nexo-prevent-sleep.service"
743
- body = f"""[Unit]
744
- Description=NEXO prevent sleep
745
-
746
- [Service]
747
- Type=simple
748
- ExecStart=/bin/bash {script_path}
749
- Environment=HOME={Path.home()}
750
- Environment=NEXO_HOME={NEXO_HOME}
751
- Environment=NEXO_CODE={NEXO_HOME}
752
- Restart=always
753
- RestartSec=5
754
-
755
- [Install]
756
- WantedBy=default.target
757
- """
758
- return service_path, body
759
-
760
-
761
- def apply_power_policy(policy: str | None = None) -> dict:
762
- policy = normalize_power_policy(policy or get_power_policy())
763
- system = platform.system()
764
- logs_dir = NEXO_HOME / "logs"
765
- logs_dir.mkdir(parents=True, exist_ok=True)
766
- details = describe_power_policy(policy=policy, system=system)
767
-
768
- if system == "Darwin":
769
- return _apply_macos_power_policy(policy, details=details)
770
- if system == "Linux":
771
- return _apply_linux_power_policy(policy, details=details)
772
- return {
773
- "ok": policy != POWER_POLICY_ALWAYS_ON,
774
- "policy": policy,
775
- "platform": system,
776
- "action": "unsupported",
777
- "message": f"Unsupported platform for prevent-sleep policy: {system}",
778
- "details": details,
779
- }
780
-
781
-
782
- def _apply_macos_power_policy(policy: str, *, details: dict | None = None) -> dict:
783
- plist_path, plist = _macos_prevent_sleep_plist()
784
- label = plist["Label"]
785
- uid = str(os.getuid())
786
- if policy == POWER_POLICY_ALWAYS_ON:
787
- details = details or describe_power_policy(policy, system="Darwin")
788
- if not details.get("helper_available"):
789
- return {
790
- "ok": False,
791
- "policy": policy,
792
- "platform": "Darwin",
793
- "action": "missing-helper",
794
- "message": f"Required helper not found: {details.get('helper_path') or 'caffeinate'}",
795
- "details": details,
796
- }
797
- LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
798
- with plist_path.open("wb") as fh:
799
- plistlib.dump(plist, fh)
800
- subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
801
- result = subprocess.run(
802
- ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
803
- capture_output=True,
804
- text=True,
805
- )
806
- ok = result.returncode == 0
807
- return {
808
- "ok": ok,
809
- "policy": policy,
810
- "platform": "Darwin",
811
- "action": "enabled",
812
- "plist_path": str(plist_path),
813
- "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
814
- "details": details,
815
- }
816
-
817
- subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
818
- if plist_path.exists():
819
- plist_path.unlink()
820
- subprocess.run(["launchctl", "remove", label], capture_output=True)
821
- return {
822
- "ok": True,
823
- "policy": policy,
824
- "platform": "Darwin",
825
- "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
826
- "plist_path": str(plist_path),
827
- "details": details or describe_power_policy(policy, system="Darwin"),
828
- }
829
-
830
-
831
- def _apply_linux_power_policy(policy: str, *, details: dict | None = None) -> dict:
832
- service_path, service_body = _linux_prevent_sleep_service()
833
- if policy == POWER_POLICY_ALWAYS_ON:
834
- details = details or describe_power_policy(policy, system="Linux")
835
- if not details.get("helper_available"):
836
- return {
837
- "ok": False,
838
- "policy": policy,
839
- "platform": "Linux",
840
- "action": "missing-helper",
841
- "message": "No Linux power helper found. Install systemd-inhibit or caffeine.",
842
- "details": details,
843
- }
844
- LINUX_SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
845
- service_path.write_text(service_body)
846
- subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
847
- result = subprocess.run(
848
- ["systemctl", "--user", "enable", "--now", "nexo-prevent-sleep.service"],
849
- capture_output=True,
850
- text=True,
851
- )
852
- ok = result.returncode == 0
853
- return {
854
- "ok": ok,
855
- "policy": policy,
856
- "platform": "Linux",
857
- "action": "enabled",
858
- "service_path": str(service_path),
859
- "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
860
- "details": details,
861
- }
862
-
863
- subprocess.run(["systemctl", "--user", "disable", "--now", "nexo-prevent-sleep.service"], capture_output=True)
864
- if service_path.exists():
865
- service_path.unlink()
866
- subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
867
- return {
868
- "ok": True,
869
- "policy": policy,
870
- "platform": "Linux",
871
- "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
872
- "service_path": str(service_path),
873
- "details": details or describe_power_policy(policy, system="Linux"),
874
- }