nexo-brain 7.12.2 → 7.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/nexo-brain.js +242 -23
- package/bin/windows-wsl-bridge.js +0 -0
- package/package.json +2 -2
- package/src/cli.py +15 -0
- package/src/health_check.py +45 -9
- package/src/requirements.txt +5 -2
- package/src/support_snapshot.py +4 -1
- package/src/windows_runtime.py +257 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.12.
|
|
3
|
+
"version": "7.12.4",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/bin/nexo-brain.js
CHANGED
|
@@ -111,6 +111,10 @@ const DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _MODEL_DEFAULTS.claude_code.reasoni
|
|
|
111
111
|
const DEFAULT_CODEX_MODEL = _MODEL_DEFAULTS.codex.model;
|
|
112
112
|
const DEFAULT_CODEX_REASONING_EFFORT = _MODEL_DEFAULTS.codex.reasoning_effort || "";
|
|
113
113
|
|
|
114
|
+
function isDesktopManagedInstall() {
|
|
115
|
+
return String(process.env.NEXO_DESKTOP_MANAGED || "").trim() === "1";
|
|
116
|
+
}
|
|
117
|
+
|
|
114
118
|
// v6.0.0 — Hook manifest is the single source of truth for which hook
|
|
115
119
|
// handlers get registered. Both plugin mode (hooks/hooks.json) and npm
|
|
116
120
|
// mode (this installer's registerAllCoreHooks) read from the same file.
|
|
@@ -223,6 +227,62 @@ function run(cmd, opts = {}) {
|
|
|
223
227
|
|
|
224
228
|
function log(msg) {
|
|
225
229
|
console.log(` ${msg}`);
|
|
230
|
+
try {
|
|
231
|
+
const logPath = path.join(
|
|
232
|
+
NEXO_HOME,
|
|
233
|
+
"runtime",
|
|
234
|
+
"bootstrap",
|
|
235
|
+
"logs",
|
|
236
|
+
"brain-install.log"
|
|
237
|
+
);
|
|
238
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
239
|
+
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${msg}\n`);
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let _stagedRuntimeBundleRoot = null;
|
|
244
|
+
let _stagedRuntimeCleanup = null;
|
|
245
|
+
|
|
246
|
+
function getRuntimeBundleRoot() {
|
|
247
|
+
if (_stagedRuntimeBundleRoot) {
|
|
248
|
+
return _stagedRuntimeBundleRoot;
|
|
249
|
+
}
|
|
250
|
+
const bundleRoot = path.resolve(__dirname, "..");
|
|
251
|
+
if (process.platform !== "linux") {
|
|
252
|
+
return bundleRoot;
|
|
253
|
+
}
|
|
254
|
+
const normalizedRoot = bundleRoot.replace(/\\/g, "/");
|
|
255
|
+
if (!normalizedRoot.startsWith("/mnt/")) {
|
|
256
|
+
return bundleRoot;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const tmpRoot = fs.mkdtempSync(path.join(require("os").tmpdir(), "nexo-brain-stage-"));
|
|
260
|
+
const stagedRoot = path.join(tmpRoot, path.basename(bundleRoot));
|
|
261
|
+
fs.mkdirSync(stagedRoot, { recursive: true });
|
|
262
|
+
|
|
263
|
+
log("Staging bundled runtime into local Linux storage...");
|
|
264
|
+
const tarCmd =
|
|
265
|
+
`tar --exclude=__pycache__ --exclude=node_modules --exclude='*.pyc' ` +
|
|
266
|
+
`--exclude='*.db' --exclude=.git ` +
|
|
267
|
+
`-C ${JSON.stringify(bundleRoot)} -cf - . | ` +
|
|
268
|
+
`tar -C ${JSON.stringify(stagedRoot)} -xf -`;
|
|
269
|
+
const stageResult = spawnSync("/bin/sh", ["-c", tarCmd], {
|
|
270
|
+
encoding: "utf8",
|
|
271
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
272
|
+
timeout: 10 * 60 * 1000,
|
|
273
|
+
});
|
|
274
|
+
if (stageResult.status !== 0) {
|
|
275
|
+
const detail = (stageResult.stderr || stageResult.stdout || "").trim() || `exit ${stageResult.status}`;
|
|
276
|
+
throw new Error(`Failed to stage bundled runtime from Windows storage: ${detail}`);
|
|
277
|
+
}
|
|
278
|
+
log(` Runtime bundle staged at ${stagedRoot}`);
|
|
279
|
+
_stagedRuntimeBundleRoot = stagedRoot;
|
|
280
|
+
_stagedRuntimeCleanup = () => {
|
|
281
|
+
try {
|
|
282
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
283
|
+
} catch {}
|
|
284
|
+
};
|
|
285
|
+
return _stagedRuntimeBundleRoot;
|
|
226
286
|
}
|
|
227
287
|
|
|
228
288
|
function readPackageJson() {
|
|
@@ -500,6 +560,15 @@ function runMandatoryModelWarmup(pythonPath, nexoHome = NEXO_HOME, { reason = "i
|
|
|
500
560
|
log("Local model warmup complete.");
|
|
501
561
|
}
|
|
502
562
|
|
|
563
|
+
function runDesktopAwareModelWarmup(pythonPath, nexoHome = NEXO_HOME, options = {}) {
|
|
564
|
+
const reason = String((options && options.reason) || "install");
|
|
565
|
+
if (isDesktopManagedInstall()) {
|
|
566
|
+
log(`Desktop-managed runtime detected — local model warmup deferred during ${reason}.`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
runMandatoryModelWarmup(pythonPath, nexoHome, options);
|
|
570
|
+
}
|
|
571
|
+
|
|
503
572
|
async function runWarmupModelsCommand(args) {
|
|
504
573
|
const dryRun = args.includes("--dry-run");
|
|
505
574
|
const json = args.includes("--json");
|
|
@@ -729,6 +798,10 @@ function resolveRuntimeSchedulePath(nexoHome) {
|
|
|
729
798
|
|
|
730
799
|
function finalizeF06Layout(python, nexoHome = NEXO_HOME) {
|
|
731
800
|
try {
|
|
801
|
+
// auto_update lives in NEXO_CODE (e.g. ~/.nexo/core or ~/.nexo/core/current),
|
|
802
|
+
// not directly in NEXO_HOME, so PYTHONPATH must point at the runtime code
|
|
803
|
+
// directory or `import auto_update` fails with ModuleNotFoundError.
|
|
804
|
+
const codeDir = runtimeCodeDir(nexoHome);
|
|
732
805
|
const result = spawnSync(
|
|
733
806
|
python,
|
|
734
807
|
[
|
|
@@ -744,8 +817,8 @@ function finalizeF06Layout(python, nexoHome = NEXO_HOME) {
|
|
|
744
817
|
env: {
|
|
745
818
|
...process.env,
|
|
746
819
|
NEXO_HOME: nexoHome,
|
|
747
|
-
NEXO_CODE:
|
|
748
|
-
PYTHONPATH:
|
|
820
|
+
NEXO_CODE: codeDir,
|
|
821
|
+
PYTHONPATH: codeDir,
|
|
749
822
|
},
|
|
750
823
|
encoding: "utf8",
|
|
751
824
|
},
|
|
@@ -1451,7 +1524,7 @@ function getDefaultSchedule(timezone) {
|
|
|
1451
1524
|
}
|
|
1452
1525
|
|
|
1453
1526
|
function writeDesktopProductMode(nexoHome) {
|
|
1454
|
-
if (
|
|
1527
|
+
if (!isDesktopManagedInstall()) return;
|
|
1455
1528
|
const configDir = resolveRuntimeConfigDir(nexoHome);
|
|
1456
1529
|
fs.mkdirSync(configDir, { recursive: true });
|
|
1457
1530
|
const target = path.join(configDir, "product-mode.json");
|
|
@@ -1478,7 +1551,7 @@ function ensureEvolutionObjectiveForCurrentProductMode(nexoHome) {
|
|
|
1478
1551
|
const brainDir = resolveRuntimeBrainDir(nexoHome);
|
|
1479
1552
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
1480
1553
|
const evoObjectivePath = path.join(brainDir, "evolution-objective.json");
|
|
1481
|
-
const desktopManaged =
|
|
1554
|
+
const desktopManaged = isDesktopManagedInstall();
|
|
1482
1555
|
let payload = null;
|
|
1483
1556
|
if (fs.existsSync(evoObjectivePath)) {
|
|
1484
1557
|
try {
|
|
@@ -1801,6 +1874,29 @@ function installClaudeCodeCli(platform) {
|
|
|
1801
1874
|
const desktopNode = String(process.env.NEXO_DESKTOP_NODE || "").trim();
|
|
1802
1875
|
const bundledNpmCli = String(process.env.NEXO_DESKTOP_NPM_CLI || "").trim();
|
|
1803
1876
|
const managedPrefix = managedClaudePrefix();
|
|
1877
|
+
const desktopManaged = isDesktopManagedInstall();
|
|
1878
|
+
|
|
1879
|
+
// OFFLINE-FIRST: detect bundled claude-code .tgz and install from it.
|
|
1880
|
+
// Path: resources/brain-bundle/claude-code/claude-code-X.Y.Z.tgz (relative
|
|
1881
|
+
// to bin/, so __dirname/../claude-code/). Falls back to PyPI/npm if absent.
|
|
1882
|
+
const bundledClaudeDir = path.join(__dirname, "..", "claude-code");
|
|
1883
|
+
if (fs.existsSync(bundledClaudeDir)) {
|
|
1884
|
+
const tgzFiles = fs.readdirSync(bundledClaudeDir).filter((f) => f.endsWith(".tgz"));
|
|
1885
|
+
if (tgzFiles.length > 0) {
|
|
1886
|
+
const tgzPath = path.join(bundledClaudeDir, tgzFiles[0]);
|
|
1887
|
+
log(" Installing claude-code from bundled tarball (offline)...");
|
|
1888
|
+
spawnSync(
|
|
1889
|
+
"npm",
|
|
1890
|
+
["install", "-g", "--prefix", managedPrefix, tgzPath],
|
|
1891
|
+
{ stdio: "inherit", env: installEnv },
|
|
1892
|
+
);
|
|
1893
|
+
claudeInstalled = detectInstalledClients().claude_code.path || "";
|
|
1894
|
+
if (claudeInstalled) {
|
|
1895
|
+
persistClaudeCliPath(claudeInstalled);
|
|
1896
|
+
return { installed: true, path: claudeInstalled };
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1804
1900
|
|
|
1805
1901
|
if (desktopNode && bundledNpmCli) {
|
|
1806
1902
|
spawnSync(
|
|
@@ -1818,6 +1914,23 @@ function installClaudeCodeCli(platform) {
|
|
|
1818
1914
|
}
|
|
1819
1915
|
}
|
|
1820
1916
|
|
|
1917
|
+
if (desktopManaged) {
|
|
1918
|
+
spawnSync(
|
|
1919
|
+
"npm",
|
|
1920
|
+
["install", "-g", "--prefix", managedPrefix, "@anthropic-ai/claude-code"],
|
|
1921
|
+
{
|
|
1922
|
+
stdio: "inherit",
|
|
1923
|
+
env: installEnv,
|
|
1924
|
+
},
|
|
1925
|
+
);
|
|
1926
|
+
claudeInstalled = detectInstalledClients().claude_code.path || "";
|
|
1927
|
+
if (claudeInstalled) {
|
|
1928
|
+
persistClaudeCliPath(claudeInstalled);
|
|
1929
|
+
return { installed: true, path: claudeInstalled };
|
|
1930
|
+
}
|
|
1931
|
+
return { installed: false, path: "" };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1821
1934
|
spawnSync("npx", ["-y", "@anthropic-ai/claude-code", "--version"], {
|
|
1822
1935
|
stdio: "pipe",
|
|
1823
1936
|
timeout: 60000,
|
|
@@ -1847,6 +1960,7 @@ function installCodexCli() {
|
|
|
1847
1960
|
async function configureClientSetup({ lang, useDefaults, autoInstall, detected }) {
|
|
1848
1961
|
const strings = clientSetupStrings(lang);
|
|
1849
1962
|
const setup = defaultClientSetup(detected);
|
|
1963
|
+
const desktopManaged = String(process.env.NEXO_DESKTOP_MANAGED || "").trim() === "1";
|
|
1850
1964
|
setup.client_install_preferences = {
|
|
1851
1965
|
claude_code: autoInstall === "auto" ? "auto" : "ask",
|
|
1852
1966
|
codex: autoInstall === "auto" ? "auto" : "ask",
|
|
@@ -1898,6 +2012,20 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
|
|
|
1898
2012
|
const required = requiredCliClients(setup);
|
|
1899
2013
|
for (const client of required) {
|
|
1900
2014
|
if (detected[client] && detected[client].installed) continue;
|
|
2015
|
+
if (desktopManaged && client === "claude_code") {
|
|
2016
|
+
const bundledClaudeDir = path.join(__dirname, "..", "claude-code");
|
|
2017
|
+
let hasBundle = false;
|
|
2018
|
+
try {
|
|
2019
|
+
if (fs.existsSync(bundledClaudeDir)) {
|
|
2020
|
+
hasBundle = fs.readdirSync(bundledClaudeDir).some((f) => f.endsWith(".tgz"));
|
|
2021
|
+
}
|
|
2022
|
+
} catch (_) {}
|
|
2023
|
+
if (!hasBundle) {
|
|
2024
|
+
log("Claude Code install deferred to Desktop final sync.");
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
log("Bundled Claude Code tarball detected — installing offline now.");
|
|
2028
|
+
}
|
|
1901
2029
|
let shouldInstall = useDefaults || autoInstall === "auto";
|
|
1902
2030
|
if (!shouldInstall && process.stdin.isTTY && process.stdout.isTTY) {
|
|
1903
2031
|
const question = client === "claude_code" ? strings.installClaudeQ : strings.installCodexQ;
|
|
@@ -1926,6 +2054,10 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
|
|
|
1926
2054
|
}
|
|
1927
2055
|
|
|
1928
2056
|
if (setup.automation_enabled && setup.automation_backend !== "none" && !detected[setup.automation_backend]?.installed) {
|
|
2057
|
+
if (desktopManaged && setup.automation_backend === "claude_code") {
|
|
2058
|
+
log("Claude Code will be provisioned by Desktop after the core runtime is ready.");
|
|
2059
|
+
return { setup, detected };
|
|
2060
|
+
}
|
|
1929
2061
|
const label = setup.automation_backend === "claude_code" ? "Claude Code" : "Codex";
|
|
1930
2062
|
log(strings.automationDisabled(label));
|
|
1931
2063
|
setup.automation_enabled = false;
|
|
@@ -2448,6 +2580,15 @@ async function runSetup() {
|
|
|
2448
2580
|
process.exit(1);
|
|
2449
2581
|
}
|
|
2450
2582
|
|
|
2583
|
+
const bundleRoot = getRuntimeBundleRoot();
|
|
2584
|
+
const bundleSrcDir = path.join(bundleRoot, "src");
|
|
2585
|
+
const bundleTemplatesDir = path.join(bundleRoot, "templates");
|
|
2586
|
+
process.on("exit", () => {
|
|
2587
|
+
if (_stagedRuntimeCleanup) {
|
|
2588
|
+
_stagedRuntimeCleanup();
|
|
2589
|
+
}
|
|
2590
|
+
});
|
|
2591
|
+
|
|
2451
2592
|
const onboardingMigration = ensureOnboardingCompletionMarker(NEXO_HOME);
|
|
2452
2593
|
if (onboardingMigration.changed) {
|
|
2453
2594
|
log("Migrated legacy calibration completion marker.");
|
|
@@ -2479,7 +2620,7 @@ async function runSetup() {
|
|
|
2479
2620
|
}
|
|
2480
2621
|
|
|
2481
2622
|
// Recursive copy helper (skips __pycache__, .pyc, .db files)
|
|
2482
|
-
const srcDir =
|
|
2623
|
+
const srcDir = bundleSrcDir;
|
|
2483
2624
|
const copyDirRec = (src, dest) => {
|
|
2484
2625
|
fs.mkdirSync(dest, { recursive: true });
|
|
2485
2626
|
fs.readdirSync(src).forEach(item => {
|
|
@@ -2552,7 +2693,7 @@ async function runSetup() {
|
|
|
2552
2693
|
}
|
|
2553
2694
|
|
|
2554
2695
|
const migPythonForWarmup = findVenvPython(NEXO_HOME) || "python3";
|
|
2555
|
-
|
|
2696
|
+
runDesktopAwareModelWarmup(migPythonForWarmup, NEXO_HOME, { reason: "update", installRuntimeDeps: false });
|
|
2556
2697
|
|
|
2557
2698
|
// Update plugins (all .py files in plugins/)
|
|
2558
2699
|
const pluginsSrc = path.join(srcDir, "plugins");
|
|
@@ -2607,7 +2748,7 @@ async function runSetup() {
|
|
|
2607
2748
|
// hand-edited template under ~/.nexo/templates/ is replaced on
|
|
2608
2749
|
// upgrade. Keep local forks under personal/ or outside the runtime
|
|
2609
2750
|
// home to avoid silent loss.
|
|
2610
|
-
const migTemplatesSrc =
|
|
2751
|
+
const migTemplatesSrc = bundleTemplatesDir;
|
|
2611
2752
|
const migTemplatesDest = path.join(NEXO_HOME, "templates");
|
|
2612
2753
|
if (fs.existsSync(migTemplatesSrc)) {
|
|
2613
2754
|
copyDirRec(migTemplatesSrc, migTemplatesDest);
|
|
@@ -2656,7 +2797,7 @@ async function runSetup() {
|
|
|
2656
2797
|
? { runtime_repaired_from: activeRuntimeVersion }
|
|
2657
2798
|
: {}),
|
|
2658
2799
|
}, null, 2));
|
|
2659
|
-
syncRuntimePackageMetadata(
|
|
2800
|
+
syncRuntimePackageMetadata(bundleRoot, NEXO_HOME);
|
|
2660
2801
|
log("Finalizing F0.6 runtime layout...");
|
|
2661
2802
|
const migLayoutFinalize = finalizeF06Layout(migPython, NEXO_HOME);
|
|
2662
2803
|
if (!migLayoutFinalize.ok) {
|
|
@@ -2670,7 +2811,7 @@ async function runSetup() {
|
|
|
2670
2811
|
|
|
2671
2812
|
// Keep the rendered template in-memory for version tracking, but do
|
|
2672
2813
|
// not drop a loose reference file in NEXO_HOME root.
|
|
2673
|
-
const templateSrc = path.join(
|
|
2814
|
+
const templateSrc = path.join(bundleTemplatesDir, "CLAUDE.md.template");
|
|
2674
2815
|
if (fs.existsSync(templateSrc)) {
|
|
2675
2816
|
const operatorName = installed.operator_name || DEFAULT_ASSISTANT_NAME;
|
|
2676
2817
|
let claudeMd = fs.readFileSync(templateSrc, "utf8")
|
|
@@ -2742,7 +2883,7 @@ async function runSetup() {
|
|
|
2742
2883
|
// Same version — backfill crons/ if missing (for installs before crons was shipped)
|
|
2743
2884
|
const syncPython = findVenvPython(NEXO_HOME) || run("which python3") || "python3";
|
|
2744
2885
|
const cronsDest = resolveRuntimeCronsDir(NEXO_HOME);
|
|
2745
|
-
const cronsSrc = path.join(
|
|
2886
|
+
const cronsSrc = path.join(bundleSrcDir, "crons");
|
|
2746
2887
|
if (fs.existsSync(cronsSrc)) {
|
|
2747
2888
|
const copyDirRec2 = (src, dest) => {
|
|
2748
2889
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -2767,7 +2908,7 @@ async function runSetup() {
|
|
|
2767
2908
|
|
|
2768
2909
|
// Same version — refresh packaged core skills/templates/runtime helpers too.
|
|
2769
2910
|
const skillsCoreDest = path.join(NEXO_HOME, "core", "skills");
|
|
2770
|
-
const skillsCoreSrc = path.join(
|
|
2911
|
+
const skillsCoreSrc = path.join(bundleSrcDir, "skills");
|
|
2771
2912
|
if (fs.existsSync(skillsCoreSrc)) {
|
|
2772
2913
|
const copyDirRec3 = (src, dest) => {
|
|
2773
2914
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -2784,16 +2925,16 @@ async function runSetup() {
|
|
|
2784
2925
|
}
|
|
2785
2926
|
|
|
2786
2927
|
["skills_runtime.py"].forEach((fname) => {
|
|
2787
|
-
const srcFile = path.join(
|
|
2928
|
+
const srcFile = path.join(bundleSrcDir, fname);
|
|
2788
2929
|
const destFile = path.join(NEXO_HOME, "core", fname);
|
|
2789
2930
|
if (fs.existsSync(srcFile)) {
|
|
2790
2931
|
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
|
2791
2932
|
fs.copyFileSync(srcFile, destFile);
|
|
2792
2933
|
}
|
|
2793
2934
|
});
|
|
2794
|
-
syncRuntimePackageMetadata(
|
|
2935
|
+
syncRuntimePackageMetadata(bundleRoot, NEXO_HOME);
|
|
2795
2936
|
|
|
2796
|
-
const templatesSrc =
|
|
2937
|
+
const templatesSrc = bundleTemplatesDir;
|
|
2797
2938
|
const templatesDest = path.join(NEXO_HOME, "templates");
|
|
2798
2939
|
if (fs.existsSync(templatesSrc)) {
|
|
2799
2940
|
fs.mkdirSync(templatesDest, { recursive: true });
|
|
@@ -2821,10 +2962,26 @@ async function runSetup() {
|
|
|
2821
2962
|
throw new Error(`F0.6 layout finalization failed: ${syncLayoutFinalize.error}`);
|
|
2822
2963
|
}
|
|
2823
2964
|
|
|
2824
|
-
|
|
2965
|
+
runDesktopAwareModelWarmup(syncPython, NEXO_HOME, { reason: "repair" });
|
|
2825
2966
|
logMacPermissionsNotice(NEXO_HOME, syncPython);
|
|
2826
2967
|
|
|
2827
2968
|
log(`Already at v${currentVersion}. No migration needed.`);
|
|
2969
|
+
|
|
2970
|
+
// Ensure bundled Claude Code is installed even when migration is skipped.
|
|
2971
|
+
try {
|
|
2972
|
+
const _claudeCheck = detectInstalledClients().claude_code;
|
|
2973
|
+
if (!_claudeCheck.installed) {
|
|
2974
|
+
const _bundledClaudeDir = path.join(__dirname, "..", "claude-code");
|
|
2975
|
+
if (fs.existsSync(_bundledClaudeDir)) {
|
|
2976
|
+
const _tgzFiles = fs.readdirSync(_bundledClaudeDir).filter((f) => f.endsWith(".tgz"));
|
|
2977
|
+
if (_tgzFiles.length > 0) {
|
|
2978
|
+
log("Bundled Claude Code tarball detected after migration-skip — installing offline.");
|
|
2979
|
+
installClaudeCodeCli(process.platform);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
} catch (_e) {}
|
|
2984
|
+
|
|
2828
2985
|
closeReadline();
|
|
2829
2986
|
return;
|
|
2830
2987
|
} catch (e) {
|
|
@@ -3316,11 +3473,22 @@ async function runSetup() {
|
|
|
3316
3473
|
// Use venv python if available, otherwise fall back to system python with --break-system-packages
|
|
3317
3474
|
const pipPython = fs.existsSync(venvPython) ? venvPython : python;
|
|
3318
3475
|
const requirementsFile = path.join(__dirname, "..", "src", "requirements.txt");
|
|
3319
|
-
|
|
3476
|
+
// Detect bundled wheels in resources/python-wheels (offline-first). If
|
|
3477
|
+
// present, pip uses --no-index --find-links to install without internet.
|
|
3478
|
+
// Falls back to PyPI if bundle not found.
|
|
3479
|
+
const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
|
|
3480
|
+
const useBundle = fs.existsSync(bundledWheelsDir);
|
|
3481
|
+
const pipArgs = useBundle
|
|
3482
|
+
? ["-m", "pip", "install", "--no-index", "--find-links", bundledWheelsDir, "--progress-bar", "off", "-r", requirementsFile]
|
|
3483
|
+
: ["-m", "pip", "install", "-v", "--progress-bar", "off", "--default-timeout=60", "-r", requirementsFile];
|
|
3320
3484
|
if (!fs.existsSync(venvPython)) {
|
|
3321
|
-
pipArgs.push("--break-system-packages");
|
|
3485
|
+
pipArgs.push("--break-system-packages");
|
|
3486
|
+
}
|
|
3487
|
+
if (useBundle) {
|
|
3488
|
+
log(" Installing Python deps from bundled wheels (offline)...");
|
|
3489
|
+
} else {
|
|
3490
|
+
log(" Installing Python deps from PyPI (online)...");
|
|
3322
3491
|
}
|
|
3323
|
-
|
|
3324
3492
|
const pipInstall = spawnSync(pipPython, pipArgs, { stdio: "inherit" });
|
|
3325
3493
|
if (pipInstall.status !== 0) {
|
|
3326
3494
|
log("Failed to install Python dependencies.");
|
|
@@ -3332,7 +3500,54 @@ async function runSetup() {
|
|
|
3332
3500
|
python = venvPython;
|
|
3333
3501
|
}
|
|
3334
3502
|
log("Dependencies installed.");
|
|
3335
|
-
|
|
3503
|
+
|
|
3504
|
+
// OFFLINE-FIRST: copy bundled LLM models to runtime/models BEFORE warmup,
|
|
3505
|
+
// so fastembed finds them locally and skips the ~217MB HuggingFace download.
|
|
3506
|
+
// Bundle layout: resources/brain-bundle/models/<source-repo-name>/<all files>.
|
|
3507
|
+
// Target layout: <NEXO_HOME>/runtime/models/<spec.name slugified>/<revision>/<files>.
|
|
3508
|
+
// We map by source_repo basename to match local_model_manifest.json.
|
|
3509
|
+
const bundledModelsDir = path.join(__dirname, "..", "models");
|
|
3510
|
+
if (fs.existsSync(bundledModelsDir)) {
|
|
3511
|
+
try {
|
|
3512
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "src", "local_model_manifest.json"), "utf8"));
|
|
3513
|
+
const runtimeModelsDir = path.join(NEXO_HOME, "runtime", "models");
|
|
3514
|
+
let modelsCopied = 0;
|
|
3515
|
+
for (const spec of manifest.models || []) {
|
|
3516
|
+
// Bundle layout supports either model_id basename (e.g.
|
|
3517
|
+
// "bge-base-en-v1.5" from "BAAI/bge-base-en-v1.5") or source_repo
|
|
3518
|
+
// basename (e.g. "bge-base-en-v1.5-onnx-q" from "qdrant/...").
|
|
3519
|
+
const modelIdName = (spec.model_id || "").split("/").pop();
|
|
3520
|
+
const sourceRepoName = (spec.source_repo || "").split("/").pop();
|
|
3521
|
+
let sourceDir = path.join(bundledModelsDir, modelIdName);
|
|
3522
|
+
if (!fs.existsSync(sourceDir)) {
|
|
3523
|
+
sourceDir = path.join(bundledModelsDir, sourceRepoName);
|
|
3524
|
+
}
|
|
3525
|
+
if (!fs.existsSync(sourceDir)) continue;
|
|
3526
|
+
const slug = (spec.name || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3527
|
+
const targetDir = path.join(runtimeModelsDir, slug, spec.revision);
|
|
3528
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
3529
|
+
for (const f of (spec.required_files || [])) {
|
|
3530
|
+
const src = path.join(sourceDir, f.path);
|
|
3531
|
+
const dst = path.join(targetDir, f.path);
|
|
3532
|
+
if (fs.existsSync(src) && !fs.existsSync(dst)) {
|
|
3533
|
+
fs.copyFileSync(src, dst);
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
// Write the lock file to match revision (avoids re-download).
|
|
3537
|
+
fs.writeFileSync(path.join(targetDir, ".nexo-model-lock.json"), JSON.stringify({
|
|
3538
|
+
name: spec.name, kind: spec.kind, model_id: spec.model_id,
|
|
3539
|
+
source_repo: spec.source_repo, revision: spec.revision, model_file: spec.model_file,
|
|
3540
|
+
required_files: spec.required_files,
|
|
3541
|
+
}, null, 2));
|
|
3542
|
+
modelsCopied++;
|
|
3543
|
+
}
|
|
3544
|
+
if (modelsCopied > 0) log(` Copied ${modelsCopied} pre-bundled LLM model(s) (offline).`);
|
|
3545
|
+
} catch (err) {
|
|
3546
|
+
log(` WARN: bundled models copy failed: ${err.message}`);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
runDesktopAwareModelWarmup(python, NEXO_HOME, { reason: "install", installRuntimeDeps: false });
|
|
3336
3551
|
|
|
3337
3552
|
// Step 4: Create ~/.nexo/
|
|
3338
3553
|
log("Setting up NEXO home...");
|
|
@@ -3381,15 +3596,15 @@ async function runSetup() {
|
|
|
3381
3596
|
files_updated: 0,
|
|
3382
3597
|
}, null, 2)
|
|
3383
3598
|
);
|
|
3384
|
-
syncRuntimePackageMetadata(
|
|
3599
|
+
syncRuntimePackageMetadata(bundleRoot, NEXO_HOME);
|
|
3385
3600
|
|
|
3386
3601
|
// Copy source files
|
|
3387
3602
|
log("Copying core runtime files...");
|
|
3388
|
-
const srcDir =
|
|
3603
|
+
const srcDir = bundleSrcDir;
|
|
3389
3604
|
const pluginsSrcDir = path.join(srcDir, "plugins");
|
|
3390
3605
|
const scriptsSrcDir = path.join(srcDir, "scripts");
|
|
3391
3606
|
const skillsSrcDir = path.join(srcDir, "skills");
|
|
3392
|
-
const templateDir =
|
|
3607
|
+
const templateDir = bundleTemplatesDir;
|
|
3393
3608
|
|
|
3394
3609
|
// Recursive copy helper (skips __pycache__, .pyc, .db files)
|
|
3395
3610
|
const copyDirRecursive = (src, dest) => {
|
|
@@ -3407,7 +3622,7 @@ async function runSetup() {
|
|
|
3407
3622
|
};
|
|
3408
3623
|
|
|
3409
3624
|
// Core flat files (single .py files in src/)
|
|
3410
|
-
const coreFiles = getCoreRuntimeFlatFiles();
|
|
3625
|
+
const coreFiles = getCoreRuntimeFlatFiles(srcDir);
|
|
3411
3626
|
coreFiles.forEach((f) => {
|
|
3412
3627
|
const src = path.join(srcDir, f);
|
|
3413
3628
|
if (fs.existsSync(src)) {
|
|
@@ -4429,6 +4644,10 @@ See ~/.nexo/ for configuration.
|
|
|
4429
4644
|
console.log(` \u255A${"═".repeat(bw - 2)}\u255D`);
|
|
4430
4645
|
console.log("");
|
|
4431
4646
|
|
|
4647
|
+
if (_stagedRuntimeCleanup) {
|
|
4648
|
+
_stagedRuntimeCleanup();
|
|
4649
|
+
_stagedRuntimeCleanup = null;
|
|
4650
|
+
}
|
|
4432
4651
|
closeReadline();
|
|
4433
4652
|
}
|
|
4434
4653
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.12.
|
|
3
|
+
"version": "7.12.4",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
5
|
+
"description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
7
7
|
"bin": {
|
|
8
8
|
"nexo-brain": "bin/nexo-brain.js",
|
package/src/cli.py
CHANGED
|
@@ -2518,6 +2518,7 @@ def _skills_compose(args):
|
|
|
2518
2518
|
def _uninstall(args):
|
|
2519
2519
|
"""Stop all crons, remove MCP config and hooks, preserve user data."""
|
|
2520
2520
|
from pathlib import Path
|
|
2521
|
+
from windows_runtime import cleanup_windows_host_artifacts, running_inside_wsl, running_from_windows_host
|
|
2521
2522
|
|
|
2522
2523
|
nexo_home = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo"))
|
|
2523
2524
|
dry_run = args.dry_run
|
|
@@ -2566,6 +2567,20 @@ def _uninstall(args):
|
|
|
2566
2567
|
if not dry_run:
|
|
2567
2568
|
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
2568
2569
|
|
|
2570
|
+
if running_inside_wsl() or running_from_windows_host():
|
|
2571
|
+
host_cleanup = cleanup_windows_host_artifacts(
|
|
2572
|
+
delete_data=delete_data,
|
|
2573
|
+
dry_run=dry_run,
|
|
2574
|
+
)
|
|
2575
|
+
for action in host_cleanup.get("actions", []):
|
|
2576
|
+
log_action(
|
|
2577
|
+
action.get("category", "remove-win-host-artifact"),
|
|
2578
|
+
action.get("detail", ""),
|
|
2579
|
+
action.get("path", ""),
|
|
2580
|
+
)
|
|
2581
|
+
for error in host_cleanup.get("errors", []):
|
|
2582
|
+
errors.append(f"Windows host cleanup: {error}")
|
|
2583
|
+
|
|
2569
2584
|
# ── 2. Remove MCP server and hooks from Claude Code settings ──
|
|
2570
2585
|
claude_settings = Path.home() / ".claude" / "settings.json"
|
|
2571
2586
|
if claude_settings.exists():
|
package/src/health_check.py
CHANGED
|
@@ -25,7 +25,7 @@ import time
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import Any
|
|
27
27
|
|
|
28
|
-
from windows_runtime import running_inside_wsl, windows_runtime_status
|
|
28
|
+
from windows_runtime import query_windows_host_tasks, running_inside_wsl, windows_runtime_status
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def _nexo_home() -> Path:
|
|
@@ -83,17 +83,53 @@ def _check_database() -> dict:
|
|
|
83
83
|
|
|
84
84
|
def _check_crons() -> dict:
|
|
85
85
|
out: dict[str, Any] = {}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
system = platform.system()
|
|
87
|
+
release = platform.release()
|
|
88
|
+
|
|
89
|
+
if system == "Darwin":
|
|
90
|
+
agents_dir = Path.home() / "Library" / "LaunchAgents"
|
|
91
|
+
out["platform"] = "macos"
|
|
92
|
+
if agents_dir.is_dir():
|
|
93
|
+
try:
|
|
94
|
+
plists = [p for p in agents_dir.glob("com.nexo.*.plist")]
|
|
95
|
+
out["launch_agents"] = len(plists)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
out["error"] = str(exc)
|
|
98
|
+
out["status"] = "degraded"
|
|
99
|
+
return out
|
|
100
|
+
out["status"] = "ok"
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
if system == "Linux":
|
|
104
|
+
unit_dir = Path.home() / ".config" / "systemd" / "user"
|
|
105
|
+
inside_wsl = running_inside_wsl(system=system, release=release)
|
|
106
|
+
out["platform"] = "wsl" if inside_wsl else "linux"
|
|
107
|
+
out["inside_wsl"] = inside_wsl
|
|
89
108
|
try:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
out["
|
|
109
|
+
services = sorted(unit_dir.glob("nexo-*.service")) if unit_dir.is_dir() else []
|
|
110
|
+
timers = sorted(unit_dir.glob("nexo-*.timer")) if unit_dir.is_dir() else []
|
|
111
|
+
out["systemd_services"] = len(services)
|
|
112
|
+
out["systemd_timers"] = len(timers)
|
|
93
113
|
except Exception as exc:
|
|
94
114
|
out["error"] = str(exc)
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
out["status"] = "degraded"
|
|
116
|
+
return out
|
|
117
|
+
if inside_wsl:
|
|
118
|
+
out["windows_host_tasks"] = query_windows_host_tasks()
|
|
119
|
+
out["status"] = "ok" if (out.get("systemd_services", 0) or out.get("systemd_timers", 0)) else "degraded"
|
|
120
|
+
if out["status"] == "degraded":
|
|
121
|
+
out["reason"] = "no_nexo_systemd_units_detected"
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
if system == "Windows":
|
|
125
|
+
out["platform"] = "windows"
|
|
126
|
+
out["windows_host_tasks"] = query_windows_host_tasks()
|
|
127
|
+
out["status"] = "ok" if out["windows_host_tasks"].get("available") else "degraded"
|
|
128
|
+
if out["status"] == "degraded":
|
|
129
|
+
out["reason"] = "windows_task_scheduler_unavailable"
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
out["platform"] = "unknown"
|
|
97
133
|
out["status"] = "ok"
|
|
98
134
|
return out
|
|
99
135
|
|
package/src/requirements.txt
CHANGED
|
@@ -12,8 +12,11 @@ tomli; python_version < "3.11"
|
|
|
12
12
|
anthropic>=0.80.0
|
|
13
13
|
openai>=2.20.0
|
|
14
14
|
|
|
15
|
-
# Embedding model (optional but recommended for cognitive features)
|
|
16
|
-
|
|
15
|
+
# Embedding model (optional but recommended for cognitive features).
|
|
16
|
+
# Pin >=0.8.0: older releases require Python <3.12 and pip iterates each
|
|
17
|
+
# obsolete version for ~10 min on Ubuntu 24.04 (Python 3.12) before finding
|
|
18
|
+
# a compatible one. Verified empirically during Win11 clean install bootstrap.
|
|
19
|
+
fastembed>=0.8.0
|
|
17
20
|
|
|
18
21
|
# Dashboard (optional, only needed for `python -m dashboard.app`)
|
|
19
22
|
fastapi
|
package/src/support_snapshot.py
CHANGED
|
@@ -20,7 +20,7 @@ import paths
|
|
|
20
20
|
from doctor.formatters import format_report
|
|
21
21
|
from doctor.orchestrator import run_doctor
|
|
22
22
|
from health_check import collect as collect_health
|
|
23
|
-
from windows_runtime import running_inside_wsl, windows_runtime_status
|
|
23
|
+
from windows_runtime import query_windows_host_tasks, running_inside_wsl, windows_runtime_status
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def _nexo_home() -> Path:
|
|
@@ -125,6 +125,9 @@ def collect_snapshot(*, log_lines: int = 80, include_doctor: bool = False) -> di
|
|
|
125
125
|
"is_wsl": running_inside_wsl(system=system, release=release),
|
|
126
126
|
},
|
|
127
127
|
"windows_runtime": windows_runtime_status(_nexo_home(), system=system, release=release),
|
|
128
|
+
"windows_host": {
|
|
129
|
+
"tasks": query_windows_host_tasks(),
|
|
130
|
+
},
|
|
128
131
|
"paths": _path_status(),
|
|
129
132
|
"health": collect_health(),
|
|
130
133
|
"logs": _recent_logs(log_lines),
|
package/src/windows_runtime.py
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"""Helpers for Windows/WSL runtime diagnostics."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
4
7
|
import os
|
|
5
8
|
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
6
11
|
from pathlib import Path
|
|
7
12
|
from typing import Any
|
|
8
13
|
|
|
@@ -36,6 +41,257 @@ def is_windows_mount_path(candidate: Path) -> bool:
|
|
|
36
41
|
return normalized.startswith("/mnt/")
|
|
37
42
|
|
|
38
43
|
|
|
44
|
+
def _default_windows_runner(args: list[str], *, timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
|
45
|
+
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_windows_host_binary(
|
|
49
|
+
command: str,
|
|
50
|
+
*,
|
|
51
|
+
which_func=shutil.which,
|
|
52
|
+
) -> str:
|
|
53
|
+
direct = str(which_func(command) or "").strip()
|
|
54
|
+
if direct:
|
|
55
|
+
return direct
|
|
56
|
+
fallbacks = {
|
|
57
|
+
"powershell.exe": [
|
|
58
|
+
"/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
|
|
59
|
+
"/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
|
|
60
|
+
],
|
|
61
|
+
"schtasks.exe": [
|
|
62
|
+
"/mnt/c/Windows/System32/schtasks.exe",
|
|
63
|
+
"/c/Windows/System32/schtasks.exe",
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
for candidate in fallbacks.get(command.lower(), ()):
|
|
67
|
+
if Path(candidate).exists():
|
|
68
|
+
return candidate
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def windows_host_interop_available(*, which_func=shutil.which) -> bool:
|
|
73
|
+
return bool(
|
|
74
|
+
resolve_windows_host_binary("powershell.exe", which_func=which_func)
|
|
75
|
+
or resolve_windows_host_binary("schtasks.exe", which_func=which_func)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def query_windows_host_special_folders(
|
|
80
|
+
*,
|
|
81
|
+
runner=_default_windows_runner,
|
|
82
|
+
which_func=shutil.which,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
powershell = resolve_windows_host_binary("powershell.exe", which_func=which_func)
|
|
85
|
+
if not powershell:
|
|
86
|
+
return {
|
|
87
|
+
"available": False,
|
|
88
|
+
"error": "powershell_missing",
|
|
89
|
+
"folders": {},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
script = (
|
|
93
|
+
"$obj = [ordered]@{"
|
|
94
|
+
"LocalApplicationData=[Environment]::GetFolderPath('LocalApplicationData');"
|
|
95
|
+
"ApplicationData=[Environment]::GetFolderPath('ApplicationData');"
|
|
96
|
+
"Programs=[Environment]::GetFolderPath('Programs')"
|
|
97
|
+
"};"
|
|
98
|
+
"$obj | ConvertTo-Json -Compress"
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
result = runner([powershell, "-NoProfile", "-Command", script], timeout=20)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
return {
|
|
104
|
+
"available": False,
|
|
105
|
+
"error": str(exc),
|
|
106
|
+
"folders": {},
|
|
107
|
+
}
|
|
108
|
+
raw = str(result.stdout or "").strip()
|
|
109
|
+
if result.returncode != 0 or not raw:
|
|
110
|
+
return {
|
|
111
|
+
"available": False,
|
|
112
|
+
"error": str(result.stderr or result.stdout or "windows_special_folders_failed").strip(),
|
|
113
|
+
"folders": {},
|
|
114
|
+
}
|
|
115
|
+
try:
|
|
116
|
+
payload = json.loads(raw)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
return {
|
|
119
|
+
"available": False,
|
|
120
|
+
"error": f"invalid_json: {exc}",
|
|
121
|
+
"folders": {},
|
|
122
|
+
}
|
|
123
|
+
if not isinstance(payload, dict):
|
|
124
|
+
return {
|
|
125
|
+
"available": False,
|
|
126
|
+
"error": "invalid_payload",
|
|
127
|
+
"folders": {},
|
|
128
|
+
}
|
|
129
|
+
folders = {str(key): str(value or "").strip() for key, value in payload.items()}
|
|
130
|
+
return {
|
|
131
|
+
"available": True,
|
|
132
|
+
"folders": folders,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def query_windows_host_tasks(
|
|
137
|
+
*,
|
|
138
|
+
runner=_default_windows_runner,
|
|
139
|
+
which_func=shutil.which,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
schtasks = resolve_windows_host_binary("schtasks.exe", which_func=which_func)
|
|
142
|
+
if not schtasks:
|
|
143
|
+
return {
|
|
144
|
+
"available": False,
|
|
145
|
+
"error": "schtasks_missing",
|
|
146
|
+
"tasks": [],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
result = runner([schtasks, "/Query", "/FO", "CSV", "/NH"], timeout=30)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
return {
|
|
153
|
+
"available": False,
|
|
154
|
+
"error": str(exc),
|
|
155
|
+
"tasks": [],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return {
|
|
160
|
+
"available": False,
|
|
161
|
+
"error": str(result.stderr or result.stdout or "schtasks_query_failed").strip(),
|
|
162
|
+
"tasks": [],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
tasks: list[str] = []
|
|
166
|
+
try:
|
|
167
|
+
reader = csv.reader(io.StringIO(str(result.stdout or "")))
|
|
168
|
+
for row in reader:
|
|
169
|
+
if not row:
|
|
170
|
+
continue
|
|
171
|
+
name = str(row[0] or "").strip()
|
|
172
|
+
if name and "nexo" in name.lower():
|
|
173
|
+
tasks.append(name)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
return {
|
|
176
|
+
"available": False,
|
|
177
|
+
"error": f"invalid_csv: {exc}",
|
|
178
|
+
"tasks": [],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"available": True,
|
|
183
|
+
"tasks": sorted(set(tasks)),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build_windows_host_cleanup_plan(
|
|
188
|
+
*,
|
|
189
|
+
delete_data: bool = False,
|
|
190
|
+
runner=_default_windows_runner,
|
|
191
|
+
which_func=shutil.which,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
folders_payload = query_windows_host_special_folders(runner=runner, which_func=which_func)
|
|
194
|
+
tasks_payload = query_windows_host_tasks(runner=runner, which_func=which_func)
|
|
195
|
+
folders = folders_payload.get("folders", {}) if folders_payload.get("available") else {}
|
|
196
|
+
local = str(folders.get("LocalApplicationData", "")).strip()
|
|
197
|
+
roaming = str(folders.get("ApplicationData", "")).strip()
|
|
198
|
+
programs = str(folders.get("Programs", "")).strip()
|
|
199
|
+
|
|
200
|
+
runtime_paths = [
|
|
201
|
+
path for path in (
|
|
202
|
+
f"{local}\\Programs\\NEXO Desktop" if local else "",
|
|
203
|
+
f"{local}\\Programs\\NEXO Desktop Support" if local else "",
|
|
204
|
+
) if path
|
|
205
|
+
]
|
|
206
|
+
data_paths = [
|
|
207
|
+
path for path in (
|
|
208
|
+
f"{roaming}\\NEXO Desktop" if roaming else "",
|
|
209
|
+
f"{roaming}\\NEXO Desktop Support" if roaming else "",
|
|
210
|
+
f"{local}\\NEXO Desktop" if local else "",
|
|
211
|
+
f"{local}\\NEXO Desktop Support" if local else "",
|
|
212
|
+
) if path
|
|
213
|
+
]
|
|
214
|
+
shortcut_paths = [
|
|
215
|
+
path for path in (
|
|
216
|
+
f"{programs}\\NEXO Desktop.lnk" if programs else "",
|
|
217
|
+
f"{programs}\\NEXO Desktop Support.lnk" if programs else "",
|
|
218
|
+
) if path
|
|
219
|
+
]
|
|
220
|
+
return {
|
|
221
|
+
"available": bool(folders_payload.get("available") or tasks_payload.get("available")),
|
|
222
|
+
"folders": folders,
|
|
223
|
+
"runtime_paths": runtime_paths,
|
|
224
|
+
"data_paths": data_paths if delete_data else [],
|
|
225
|
+
"shortcut_paths": shortcut_paths,
|
|
226
|
+
"tasks": list(tasks_payload.get("tasks", [])),
|
|
227
|
+
"errors": [
|
|
228
|
+
value for value in (
|
|
229
|
+
folders_payload.get("error"),
|
|
230
|
+
tasks_payload.get("error"),
|
|
231
|
+
) if value
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cleanup_windows_host_artifacts(
|
|
237
|
+
*,
|
|
238
|
+
delete_data: bool = False,
|
|
239
|
+
dry_run: bool = False,
|
|
240
|
+
runner=_default_windows_runner,
|
|
241
|
+
which_func=shutil.which,
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
powershell = resolve_windows_host_binary("powershell.exe", which_func=which_func)
|
|
244
|
+
schtasks = resolve_windows_host_binary("schtasks.exe", which_func=which_func)
|
|
245
|
+
plan = build_windows_host_cleanup_plan(
|
|
246
|
+
delete_data=delete_data,
|
|
247
|
+
runner=runner,
|
|
248
|
+
which_func=which_func,
|
|
249
|
+
)
|
|
250
|
+
actions: list[dict[str, str]] = []
|
|
251
|
+
errors = list(plan.get("errors", []))
|
|
252
|
+
|
|
253
|
+
for path in plan["runtime_paths"]:
|
|
254
|
+
actions.append({"category": "remove-win-host-app", "detail": Path(path).name, "path": path})
|
|
255
|
+
for path in plan["shortcut_paths"]:
|
|
256
|
+
actions.append({"category": "remove-win-host-shortcut", "detail": Path(path).name, "path": path})
|
|
257
|
+
for path in plan["data_paths"]:
|
|
258
|
+
actions.append({"category": "remove-win-host-data", "detail": Path(path).name, "path": path})
|
|
259
|
+
for task_name in plan["tasks"]:
|
|
260
|
+
actions.append({"category": "remove-win-host-task", "detail": task_name, "path": task_name})
|
|
261
|
+
|
|
262
|
+
if dry_run or not plan["available"]:
|
|
263
|
+
return {"available": plan["available"], "actions": actions, "errors": errors}
|
|
264
|
+
|
|
265
|
+
if powershell:
|
|
266
|
+
remove_targets = plan["runtime_paths"] + plan["shortcut_paths"] + plan["data_paths"]
|
|
267
|
+
if remove_targets:
|
|
268
|
+
quoted = ", ".join(json.dumps(target) for target in remove_targets)
|
|
269
|
+
script = (
|
|
270
|
+
f"$targets = @({quoted}); "
|
|
271
|
+
"foreach ($target in $targets) { "
|
|
272
|
+
"if (Test-Path -LiteralPath $target) { "
|
|
273
|
+
"Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue "
|
|
274
|
+
"} }"
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
result = runner([powershell, "-NoProfile", "-Command", script], timeout=60)
|
|
278
|
+
if result.returncode != 0:
|
|
279
|
+
errors.append(str(result.stderr or result.stdout or "windows_remove_failed").strip())
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
errors.append(str(exc))
|
|
282
|
+
|
|
283
|
+
if schtasks:
|
|
284
|
+
for task_name in plan["tasks"]:
|
|
285
|
+
try:
|
|
286
|
+
result = runner([schtasks, "/Delete", "/TN", task_name, "/F"], timeout=20)
|
|
287
|
+
if result.returncode != 0:
|
|
288
|
+
errors.append(str(result.stderr or result.stdout or f"task_delete_failed:{task_name}").strip())
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
errors.append(f"{task_name}: {exc}")
|
|
291
|
+
|
|
292
|
+
return {"available": plan["available"], "actions": actions, "errors": errors}
|
|
293
|
+
|
|
294
|
+
|
|
39
295
|
def windows_runtime_status(nexo_home: Path, *, system: str | None = None, release: str | None = None) -> dict[str, Any]:
|
|
40
296
|
resolved_system = str(system or platform.system() or "").strip()
|
|
41
297
|
resolved_release = str(release or platform.release() or "").strip()
|
|
@@ -63,6 +319,7 @@ def windows_runtime_status(nexo_home: Path, *, system: str | None = None, releas
|
|
|
63
319
|
"inside_wsl": inside_wsl,
|
|
64
320
|
"windows_host_bridge": running_from_windows_host(),
|
|
65
321
|
"bridge_mode": bridge_mode(),
|
|
322
|
+
"windows_host_interop": windows_host_interop_available(),
|
|
66
323
|
"wsl_distro": str(os.environ.get("WSL_DISTRO_NAME", "")).strip(),
|
|
67
324
|
"wsl_interop": bool(str(os.environ.get("WSL_INTEROP", "")).strip()),
|
|
68
325
|
"nexo_home_on_windows_mount": on_windows_mount,
|