nexo-brain 2.6.5 → 2.6.7

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
@@ -31,6 +31,8 @@ const LAUNCH_AGENTS = path.join(
31
31
  "Library",
32
32
  "LaunchAgents"
33
33
  );
34
+ const MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
35
+ const PUBLIC_CONTRIBUTION_UPSTREAM = "wazionapps/nexo";
34
36
 
35
37
  function isEphemeralInstall(nexoHome) {
36
38
  const homeDir = require("os").homedir();
@@ -114,6 +116,139 @@ function logMacPermissionsNotice(nexoHome, pythonPath = "") {
114
116
  log(" System Settings → Privacy & Security → Full Disk Access");
115
117
  }
116
118
 
119
+ function getRuntimePythonTargets(pythonPath = "") {
120
+ const candidates = [];
121
+ const venvPy = path.join(NEXO_HOME, ".venv", "bin", "python3");
122
+ if (fs.existsSync(venvPy)) candidates.push(venvPy);
123
+ if (pythonPath) candidates.push(pythonPath);
124
+ const discovered = run("which python3") || run("which python") || "";
125
+ if (discovered) candidates.push(discovered);
126
+ return [...new Set(candidates.filter(Boolean))];
127
+ }
128
+
129
+ function detectFullDiskAccessReasons(nexoHome) {
130
+ if (process.platform !== "darwin") return [];
131
+ const reasons = [];
132
+ if (isProtectedMacPath(nexoHome)) {
133
+ reasons.push(`NEXO_HOME is inside a protected macOS folder: ${nexoHome}`);
134
+ }
135
+
136
+ const logsDir = path.join(nexoHome, "logs");
137
+ if (fs.existsSync(logsDir)) {
138
+ const candidates = fs.readdirSync(logsDir).filter((name) => name.endsWith("-stderr.log"));
139
+ for (const name of candidates) {
140
+ try {
141
+ const text = fs.readFileSync(path.join(logsDir, name), "utf8");
142
+ if (text.includes("Operation not permitted")) {
143
+ reasons.push(`Recent background job stderr hit 'Operation not permitted' (${name})`);
144
+ break;
145
+ }
146
+ } catch {}
147
+ }
148
+ }
149
+ return reasons;
150
+ }
151
+
152
+ function probeFullDiskAccess(nexoHome) {
153
+ if (process.platform !== "darwin") {
154
+ return { checked: false, granted: null, probePath: "", message: "macOS-only" };
155
+ }
156
+
157
+ const candidates = [
158
+ path.join(require("os").homedir(), "Library", "Application Support", "com.apple.TCC", "TCC.db"),
159
+ path.join(require("os").homedir(), "Library", "Mail"),
160
+ path.join(require("os").homedir(), "Library", "Messages"),
161
+ path.join(require("os").homedir(), "Library", "Safari"),
162
+ path.join(require("os").homedir(), "Library", "Application Support", "AddressBook"),
163
+ ].filter((item) => fs.existsSync(item));
164
+
165
+ if (isProtectedMacPath(nexoHome)) candidates.push(nexoHome);
166
+ if (!candidates.length) {
167
+ return { checked: false, granted: null, probePath: "", message: "No probe path available." };
168
+ }
169
+
170
+ const seen = new Set();
171
+ for (const candidate of candidates) {
172
+ if (!candidate || seen.has(candidate)) continue;
173
+ seen.add(candidate);
174
+ const result = spawnSync("/bin/bash", [
175
+ "-lc",
176
+ 'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi',
177
+ "_",
178
+ candidate,
179
+ ], { encoding: "utf8" });
180
+ if (result.status === 0) {
181
+ return { checked: true, granted: true, probePath: candidate, message: "" };
182
+ }
183
+ }
184
+ return { checked: true, granted: false, probePath: candidates[0], message: "Could not verify Full Disk Access yet." };
185
+ }
186
+
187
+ async function maybeConfigureFullDiskAccess(schedule, useDefaults, pythonPath = "") {
188
+ const current = String((schedule && schedule.full_disk_access_status) || "unset").toLowerCase();
189
+ schedule.full_disk_access_status_version = 1;
190
+ const reasons = detectFullDiskAccessReasons(NEXO_HOME);
191
+ schedule.full_disk_access_reasons = reasons;
192
+
193
+ if (process.platform !== "darwin" || !reasons.length) {
194
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
195
+ return schedule;
196
+ }
197
+
198
+ if (current === "granted") {
199
+ const probe = probeFullDiskAccess(NEXO_HOME);
200
+ if (probe.granted) {
201
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
202
+ return schedule;
203
+ }
204
+ schedule.full_disk_access_status = "later";
205
+ } else if (current === "declined") {
206
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
207
+ return schedule;
208
+ }
209
+
210
+ if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
211
+ schedule.full_disk_access_status = current === "granted" ? "later" : current || "unset";
212
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
213
+ return schedule;
214
+ }
215
+
216
+ console.log("");
217
+ log("Optional macOS Full Disk Access guidance:");
218
+ log("macOS does not allow granting this automatically. NEXO can only open the correct System Settings screen and verify best effort.");
219
+ log("Reason(s) detected:");
220
+ reasons.forEach((item) => log(` - ${item}`));
221
+ log("If you proceed, add your terminal app and, if needed for background jobs, these binaries:");
222
+ log(" - /bin/bash");
223
+ getRuntimePythonTargets(pythonPath).forEach((item) => log(` - ${item}`));
224
+
225
+ const answer = (await ask(" Open Full Disk Access setup now? [y/N/later]: ")).trim().toLowerCase();
226
+ if (answer === "y" || answer === "yes") {
227
+ spawnSync("open", [MACOS_FDA_SETTINGS_URL], { stdio: "ignore" });
228
+ log("Opened System Settings → Privacy & Security → Full Disk Access.");
229
+ const followUp = (await ask(" Press Enter after granting it, or type later to skip for now: ")).trim().toLowerCase();
230
+ if (followUp === "later" || followUp === "l") {
231
+ schedule.full_disk_access_status = "later";
232
+ } else {
233
+ const probe = probeFullDiskAccess(NEXO_HOME);
234
+ if (probe.granted) {
235
+ schedule.full_disk_access_status = "granted";
236
+ log(`Full Disk Access verified via ${probe.probePath}.`);
237
+ } else {
238
+ schedule.full_disk_access_status = "later";
239
+ log("Could not verify Full Disk Access yet. NEXO will remind you later if background jobs still hit TCC.");
240
+ }
241
+ }
242
+ } else if (answer === "later" || answer === "l" || answer === "") {
243
+ schedule.full_disk_access_status = "later";
244
+ } else {
245
+ schedule.full_disk_access_status = "declined";
246
+ }
247
+
248
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
249
+ return schedule;
250
+ }
251
+
117
252
  // ══════════════════════════════════════════════════════════════════════════════
118
253
  // CORE PROCESS & HOOK DEFINITIONS
119
254
  // All core nightly/periodic processes and all 8 core hooks that make NEXO functional.
@@ -296,6 +431,25 @@ function getDefaultSchedule(timezone) {
296
431
  auto_update: true,
297
432
  power_policy: "unset",
298
433
  power_policy_version: 2,
434
+ full_disk_access_status: "unset",
435
+ full_disk_access_status_version: 1,
436
+ full_disk_access_reasons: [],
437
+ public_contribution: {
438
+ enabled: false,
439
+ mode: "unset",
440
+ consent_version: 1,
441
+ github_user: "",
442
+ upstream_repo: PUBLIC_CONTRIBUTION_UPSTREAM,
443
+ fork_repo: "",
444
+ machine_id: crypto.createHash("sha1").update(require("os").hostname()).digest("hex").slice(0, 12),
445
+ active_pr_url: "",
446
+ active_pr_number: null,
447
+ active_branch: "",
448
+ status: "unset",
449
+ cooldown_until: "",
450
+ last_run_at: "",
451
+ last_result: "",
452
+ },
299
453
  processes: {
300
454
  "cognitive-decay": { hour: 3, minute: 0 },
301
455
  "postmortem": { hour: 23, minute: 30 },
@@ -308,6 +462,23 @@ function getDefaultSchedule(timezone) {
308
462
  };
309
463
  }
310
464
 
465
+ function normalizePublicContributionConfig(config = {}) {
466
+ const base = getDefaultSchedule().public_contribution;
467
+ const merged = { ...base, ...(config || {}) };
468
+ merged.enabled = Boolean(merged.enabled);
469
+ merged.mode = String(merged.mode || "unset").toLowerCase();
470
+ merged.status = String(merged.status || "unset").toLowerCase();
471
+ merged.github_user = String(merged.github_user || "").trim();
472
+ merged.fork_repo = String(merged.fork_repo || "").trim();
473
+ merged.upstream_repo = String(merged.upstream_repo || PUBLIC_CONTRIBUTION_UPSTREAM).trim() || PUBLIC_CONTRIBUTION_UPSTREAM;
474
+ merged.active_pr_url = String(merged.active_pr_url || "").trim();
475
+ merged.active_branch = String(merged.active_branch || "").trim();
476
+ merged.cooldown_until = String(merged.cooldown_until || "").trim();
477
+ merged.last_run_at = String(merged.last_run_at || "").trim();
478
+ merged.last_result = String(merged.last_result || "").trim();
479
+ return merged;
480
+ }
481
+
311
482
  async function maybeConfigurePowerPolicy(schedule, useDefaults) {
312
483
  const current = String((schedule && schedule.power_policy) || "unset").toLowerCase();
313
484
  if (current && current !== "unset") {
@@ -340,6 +511,84 @@ async function maybeConfigurePowerPolicy(schedule, useDefaults) {
340
511
  return schedule;
341
512
  }
342
513
 
514
+ function ghLogin() {
515
+ const login = run("gh api user --jq .login 2>/dev/null");
516
+ return (login || "").trim();
517
+ }
518
+
519
+ function ensureFork(login) {
520
+ if (!login) return { ok: false, message: "Missing GitHub login.", forkRepo: "" };
521
+ const forkRepo = `${login}/nexo`;
522
+ const existing = run(`gh repo view "${forkRepo}" --json nameWithOwner 2>/dev/null`);
523
+ if (existing) return { ok: true, message: "", forkRepo };
524
+ const created = run(`gh repo fork "${PUBLIC_CONTRIBUTION_UPSTREAM}" --clone=false --remote=false 2>/dev/null`);
525
+ if (created !== null) return { ok: true, message: "", forkRepo };
526
+ return { ok: false, message: `Could not ensure fork ${forkRepo}.`, forkRepo: "" };
527
+ }
528
+
529
+ async function maybeConfigurePublicContribution(schedule, useDefaults) {
530
+ const current = normalizePublicContributionConfig((schedule && schedule.public_contribution) || {});
531
+ if (current.mode && current.mode !== "unset") {
532
+ schedule.public_contribution = current;
533
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
534
+ return schedule;
535
+ }
536
+
537
+ if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
538
+ schedule.public_contribution = current;
539
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
540
+ return schedule;
541
+ }
542
+
543
+ console.log("");
544
+ log("Optional public contribution mode:");
545
+ log("If enabled, this machine may prepare core NEXO improvements from an isolated checkout and open a Draft PR to the public repository.");
546
+ log("NEXO never auto-merges, and it pauses public evolution on this machine while that Draft PR stays open.");
547
+ log("Public contribution must never publish personal scripts, runtime data, local prompts, logs, or secrets.");
548
+ const answer = (await ask(" Enable public contribution via Draft PRs on this machine? [y/N/later]: ")).trim().toLowerCase();
549
+ if (answer === "y" || answer === "yes") {
550
+ const login = ghLogin();
551
+ if (!login) {
552
+ current.enabled = false;
553
+ current.mode = "pending_auth";
554
+ current.status = "pending_auth";
555
+ current.github_user = "";
556
+ current.fork_repo = "";
557
+ log("GitHub CLI authentication is missing. Contributor mode is pending until 'gh auth login' succeeds.");
558
+ } else {
559
+ const fork = ensureFork(login);
560
+ if (!fork.ok) {
561
+ current.enabled = false;
562
+ current.mode = "pending_auth";
563
+ current.status = "pending_auth";
564
+ current.github_user = login;
565
+ current.fork_repo = "";
566
+ log(fork.message || "Could not ensure a GitHub fork.");
567
+ } else {
568
+ current.enabled = true;
569
+ current.mode = "draft_prs";
570
+ current.status = "active";
571
+ current.github_user = login;
572
+ current.fork_repo = fork.forkRepo;
573
+ }
574
+ }
575
+ } else if (answer === "later" || answer === "l" || answer === "") {
576
+ current.enabled = false;
577
+ current.mode = "unset";
578
+ current.status = "unset";
579
+ } else {
580
+ current.enabled = false;
581
+ current.mode = "off";
582
+ current.status = "off";
583
+ current.github_user = "";
584
+ current.fork_repo = "";
585
+ }
586
+
587
+ schedule.public_contribution = current;
588
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
589
+ return schedule;
590
+ }
591
+
343
592
  /**
344
593
  * Resolve the venv python path for an existing NEXO_HOME installation.
345
594
  */
@@ -843,7 +1092,9 @@ async function main() {
843
1092
  // Regenerate all core LaunchAgents / systemd timers
844
1093
  let migSchedule = loadOrCreateSchedule(NEXO_HOME);
845
1094
  migSchedule = await maybeConfigurePowerPolicy(migSchedule, useDefaults);
1095
+ migSchedule = await maybeConfigurePublicContribution(migSchedule, useDefaults);
846
1096
  const migPython = findVenvPython(NEXO_HOME) || "python3";
1097
+ migSchedule = await maybeConfigureFullDiskAccess(migSchedule, useDefaults, migPython);
847
1098
  let migOptionals = {};
848
1099
  try {
849
1100
  const optFile = path.join(NEXO_HOME, "config", "optionals.json");
@@ -1478,6 +1729,7 @@ async function main() {
1478
1729
  );
1479
1730
 
1480
1731
  // Copy source files
1732
+ log("Copying core runtime files...");
1481
1733
  const srcDir = path.join(__dirname, "..", "src");
1482
1734
  const pluginsSrcDir = path.join(srcDir, "plugins");
1483
1735
  const scriptsSrcDir = path.join(srcDir, "scripts");
@@ -1556,6 +1808,7 @@ async function main() {
1556
1808
  fs.writeFileSync(runtimeCliPath, runtimeCli);
1557
1809
  fs.chmodSync(runtimeCliPath, 0o755);
1558
1810
 
1811
+ log("Copying core packages...");
1559
1812
  // Core packages (directories with __init__.py)
1560
1813
  ["db", "cognitive", "doctor"].forEach(pkg => {
1561
1814
  const pkgSrc = path.join(srcDir, pkg);
@@ -1564,6 +1817,7 @@ async function main() {
1564
1817
  }
1565
1818
  });
1566
1819
 
1820
+ log("Copying plugins, scripts, and templates...");
1567
1821
  // Plugins (all .py files in plugins/)
1568
1822
  fs.mkdirSync(path.join(NEXO_HOME, "plugins"), { recursive: true });
1569
1823
  if (fs.existsSync(pluginsSrcDir)) {
@@ -2116,6 +2370,8 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
2116
2370
  log("Setting up automated processes...");
2117
2371
  let schedule = loadOrCreateSchedule(NEXO_HOME);
2118
2372
  schedule = await maybeConfigurePowerPolicy(schedule, useDefaults);
2373
+ schedule = await maybeConfigurePublicContribution(schedule, useDefaults);
2374
+ schedule = await maybeConfigureFullDiskAccess(schedule, useDefaults, python);
2119
2375
  const enabledOptionals = { dashboard: doDashboard };
2120
2376
  if (isEphemeralInstall(NEXO_HOME)) {
2121
2377
  log("Ephemeral HOME/NEXO_HOME detected — skipping LaunchAgents installation.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.5",
3
+ "version": "2.6.7",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
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": {
@@ -17,7 +17,7 @@ os.environ["NEXO_SKIP_FS_INDEX"] = "1" # Skip FTS rebuild on import
17
17
 
18
18
  from db import (
19
19
  init_db, get_db, get_diary_draft, delete_diary_draft,
20
- get_orphan_sessions, write_session_diary, now_epoch,
20
+ get_orphan_sessions, read_checkpoint, write_session_diary, now_epoch,
21
21
  SESSION_STALE_SECONDS,
22
22
  )
23
23
 
@@ -67,9 +67,18 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
67
67
  context_hint = draft.get("last_context_hint", "")
68
68
  hb_count = draft.get("heartbeat_count", 0)
69
69
 
70
+ checkpoint = read_checkpoint(sid) or {}
70
71
  summary_parts = []
71
72
  if draft.get("summary_draft"):
72
73
  summary_parts.append(draft["summary_draft"])
74
+ if task and task not in " ".join(summary_parts):
75
+ summary_parts.append(f"Final task: {task}")
76
+ if context_hint:
77
+ summary_parts.append(f"Latest context: {context_hint[:300]}")
78
+ if checkpoint.get("current_goal"):
79
+ summary_parts.append(f"Current goal: {str(checkpoint['current_goal'])[:300]}")
80
+ if checkpoint.get("next_step"):
81
+ summary_parts.append(f"Next step was: {str(checkpoint['next_step'])[:240]}")
73
82
 
74
83
  tool_summary = get_tool_log_summary(sid)
75
84
  if tool_summary:
@@ -98,6 +107,10 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
98
107
  context_next = f"Last topic: {context_hint}"
99
108
  if tasks:
100
109
  context_next += f" | Tasks: {', '.join(tasks[-5:])}"
110
+ if checkpoint.get("reasoning_thread"):
111
+ context_next += f" | Reasoning: {str(checkpoint['reasoning_thread'])[:240]}"
112
+ if checkpoint.get("active_files"):
113
+ context_next += f" | Active files: {str(checkpoint['active_files'])[:180]}"
101
114
 
102
115
  write_session_diary(
103
116
  session_id=sid,
@@ -131,12 +144,19 @@ def main():
131
144
  if draft:
132
145
  promote_draft_to_diary(sid, draft, task=session.get("task", ""))
133
146
  else:
147
+ checkpoint = read_checkpoint(sid) or {}
148
+ tool_summary = get_tool_log_summary(sid)
149
+ summary_parts = [f"Auto-closed session. Task: {session.get('task', 'unknown')}"]
150
+ if checkpoint.get("current_goal"):
151
+ summary_parts.append(f"Current goal: {str(checkpoint['current_goal'])[:300]}")
152
+ if tool_summary:
153
+ summary_parts.append(tool_summary)
134
154
  write_session_diary(
135
155
  session_id=sid,
136
156
  decisions="No decisions logged",
137
- summary=f"Auto-closed session. Task: {session.get('task', 'unknown')}",
138
- context_next="",
139
- mental_state="[auto-close] No draft available. Minimal diary.",
157
+ summary=" | ".join(summary_parts),
158
+ context_next=str(checkpoint.get("next_step") or ""),
159
+ mental_state="[auto-close] No draft available. Diary reconstructed from task/checkpoint/tool logs.",
140
160
  self_critique="[auto-close] Session terminated without diary or draft.",
141
161
  source="auto-close",
142
162
  )
@@ -1236,7 +1236,7 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
1236
1236
  shutil.copy2(str(item), str(target))
1237
1237
 
1238
1238
 
1239
- def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME) -> dict:
1239
+ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
1240
1240
  import shutil
1241
1241
 
1242
1242
  packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
@@ -1253,6 +1253,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1253
1253
  copied_packages = 0
1254
1254
  copied_files = 0
1255
1255
 
1256
+ _emit_progress(progress_fn, "Copying core packages...")
1256
1257
  for pkg in packages:
1257
1258
  pkg_src = src_dir / pkg
1258
1259
  pkg_dest = dest / pkg
@@ -1266,12 +1267,14 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1266
1267
  )
1267
1268
  copied_packages += 1
1268
1269
 
1270
+ _emit_progress(progress_fn, "Copying core modules...")
1269
1271
  for name in flat_files:
1270
1272
  src_file = src_dir / name
1271
1273
  if src_file.is_file():
1272
1274
  shutil.copy2(str(src_file), str(dest / name))
1273
1275
  copied_files += 1
1274
1276
 
1277
+ _emit_progress(progress_fn, "Copying plugin modules...")
1275
1278
  plugins_src = src_dir / "plugins"
1276
1279
  plugins_dest = dest / "plugins"
1277
1280
  if plugins_src.is_dir():
@@ -1280,6 +1283,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1280
1283
  if item.is_file() and item.suffix == ".py":
1281
1284
  shutil.copy2(str(item), str(plugins_dest / item.name))
1282
1285
 
1286
+ _emit_progress(progress_fn, "Copying scripts...")
1283
1287
  scripts_src = src_dir / "scripts"
1284
1288
  scripts_dest = dest / "scripts"
1285
1289
  if scripts_src.is_dir():
@@ -1297,6 +1301,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1297
1301
  if item.suffix == ".sh":
1298
1302
  dst.chmod(0o755)
1299
1303
 
1304
+ _emit_progress(progress_fn, "Copying templates and version metadata...")
1300
1305
  templates_src = repo_dir / "templates"
1301
1306
  templates_dest = dest / "templates"
1302
1307
  if templates_src.is_dir():
@@ -1317,6 +1322,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1317
1322
  except Exception:
1318
1323
  pass
1319
1324
 
1325
+ _emit_progress(progress_fn, "Copying core skills and runtime wrapper...")
1320
1326
  skills_src = src_dir / "skills"
1321
1327
  skills_dest = dest / "skills-core"
1322
1328
  if skills_src.is_dir():
@@ -1365,10 +1371,11 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
1365
1371
  return False
1366
1372
 
1367
1373
 
1368
- def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1374
+ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bool, list[str]]:
1369
1375
  actions: list[str] = []
1370
1376
  env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(dest)}
1371
1377
  try:
1378
+ _emit_progress(progress_fn, "Initializing database and reconciling personal schedules...")
1372
1379
  init_result = subprocess.run(
1373
1380
  [
1374
1381
  sys.executable,
@@ -1394,6 +1401,7 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1394
1401
  except Exception as e:
1395
1402
  return False, [f"runtime init error: {e}"]
1396
1403
 
1404
+ _emit_progress(progress_fn, "Reconciling Python dependencies...")
1397
1405
  if _reinstall_runtime_pip_deps(dest):
1398
1406
  actions.append("pip-deps")
1399
1407
  else:
@@ -1402,6 +1410,7 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1402
1410
  sync_path = dest / "crons" / "sync.py"
1403
1411
  if sync_path.is_file():
1404
1412
  try:
1413
+ _emit_progress(progress_fn, "Syncing core cron definitions...")
1405
1414
  sync_result = subprocess.run(
1406
1415
  [sys.executable, str(sync_path)],
1407
1416
  cwd=str(dest),
@@ -1418,10 +1427,12 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
1418
1427
 
1419
1428
  from runtime_power import apply_power_policy
1420
1429
 
1430
+ _emit_progress(progress_fn, "Refreshing runtime power helper...")
1421
1431
  power_result = apply_power_policy()
1422
1432
  if power_result.get("ok"):
1423
1433
  actions.append(f"power:{power_result.get('action')}")
1424
1434
 
1435
+ _emit_progress(progress_fn, "Verifying runtime imports...")
1425
1436
  verify = subprocess.run(
1426
1437
  [sys.executable, "-c", "import server"],
1427
1438
  cwd=str(dest),
@@ -1460,11 +1471,20 @@ def _write_update_summary(summary: dict):
1460
1471
  _log(f"Failed to write update summary: {e}")
1461
1472
 
1462
1473
 
1463
- def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True) -> dict:
1474
+ def _emit_progress(progress_fn, message: str) -> None:
1475
+ if callable(progress_fn):
1476
+ try:
1477
+ progress_fn(message)
1478
+ except Exception:
1479
+ pass
1480
+
1481
+
1482
+ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True, progress_fn=None) -> dict:
1464
1483
  src_dir, repo_dir = _resolve_sync_source()
1465
1484
  if src_dir is None or repo_dir is None:
1466
1485
  return {"ok": False, "mode": "sync", "error": "No source repo recorded for this runtime."}
1467
1486
 
1487
+ _emit_progress(progress_fn, "Checking recorded source repository...")
1468
1488
  source_status = _source_repo_status(repo_dir)
1469
1489
  pulled = False
1470
1490
  old_head = source_status.get("local_head")
@@ -1474,17 +1494,21 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
1474
1494
  elif source_status.get("diverged"):
1475
1495
  _log("Source repo diverged; syncing local tree without remote pull.")
1476
1496
  elif source_status.get("behind"):
1497
+ _emit_progress(progress_fn, "Pulling latest source changes...")
1477
1498
  rc, _, pull_err = _git_in_repo(repo_dir, "pull", "--ff-only", timeout=60)
1478
1499
  if rc != 0:
1479
1500
  return {"ok": False, "mode": "sync", "error": pull_err or "git pull failed"}
1480
1501
  pulled = True
1481
1502
 
1503
+ _emit_progress(progress_fn, "Creating runtime backups...")
1482
1504
  db_backup_dir = _backup_dbs()
1483
1505
  tree_backup_dir = _backup_runtime_tree(NEXO_HOME)
1484
1506
  sync_result = {"ok": False, "mode": "sync", "pulled_source": pulled, "backup_dir": db_backup_dir, "tree_backup": tree_backup_dir}
1485
1507
  try:
1486
- copy_stats = _copy_runtime_from_source(src_dir, repo_dir, NEXO_HOME)
1487
- ok, actions = _run_runtime_post_sync(NEXO_HOME)
1508
+ _emit_progress(progress_fn, "Syncing runtime files...")
1509
+ copy_stats = _copy_runtime_from_source(src_dir, repo_dir, NEXO_HOME, progress_fn=progress_fn)
1510
+ _emit_progress(progress_fn, "Reconciling runtime state...")
1511
+ ok, actions = _run_runtime_post_sync(NEXO_HOME, progress_fn=progress_fn)
1488
1512
  if not ok:
1489
1513
  raise RuntimeError("; ".join(actions))
1490
1514
  sync_result.update({
@@ -1496,7 +1520,9 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
1496
1520
  "source": copy_stats["source"],
1497
1521
  "repo": copy_stats["repo"],
1498
1522
  })
1523
+ _emit_progress(progress_fn, "Runtime update completed.")
1499
1524
  except Exception as e:
1525
+ _emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
1500
1526
  _restore_runtime_tree(tree_backup_dir, NEXO_HOME)
1501
1527
  if db_backup_dir:
1502
1528
  _restore_dbs(db_backup_dir)
@@ -1522,15 +1548,26 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1522
1548
  "migrations": [],
1523
1549
  "power_policy": None,
1524
1550
  "power_message": None,
1551
+ "full_disk_access_status": None,
1552
+ "full_disk_access_message": None,
1525
1553
  "error": None,
1526
1554
  }
1527
1555
 
1528
- from runtime_power import apply_power_policy, ensure_power_policy_choice, get_power_policy
1556
+ from runtime_power import (
1557
+ apply_power_policy,
1558
+ ensure_power_policy_choice,
1559
+ get_power_policy,
1560
+ ensure_full_disk_access_choice,
1561
+ get_full_disk_access_status,
1562
+ )
1529
1563
 
1530
1564
  choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
1531
1565
  power_result = apply_power_policy(choice.get("policy"))
1566
+ fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason=entrypoint)
1532
1567
  result["power_policy"] = choice.get("policy") or get_power_policy()
1533
1568
  result["power_message"] = power_result.get("message")
1569
+ result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
1570
+ result["full_disk_access_message"] = fda_choice.get("message")
1534
1571
  if power_result.get("ok"):
1535
1572
  result["actions"].append(f"power:{power_result.get('action')}")
1536
1573
 
@@ -1604,6 +1641,8 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1604
1641
  result["entrypoint"] = entrypoint
1605
1642
  result["power_policy"] = choice.get("policy") or get_power_policy()
1606
1643
  result["power_message"] = power_result.get("message")
1644
+ result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
1645
+ result["full_disk_access_message"] = fda_choice.get("message")
1607
1646
  if power_result.get("ok"):
1608
1647
  actions = result.setdefault("actions", [])
1609
1648
  actions.append(f"power:{power_result.get('action')}")