Newer
Older
AI_Lab_SCRUME_Co / setup.ps1
# ============================================================
#  Установка агента AILab SCRUME Agent (Hermes Agent)
#  Обычно запускается двойным кликом по setup.cmd.
#  Вручную: powershell -NoProfile -ExecutionPolicy Bypass -File setup.ps1
#
#  Агент ставится в КОРНЕВОЙ профиль Hermes (~\hermes, он же "default"),
#  поэтому у получателя в системе ровно один агент и никаких
#  переключателей профилей. Парковка корневого профиля в Hermes не
#  работает (ядро её намеренно игнорирует), поэтому путь один — установка
#  в корень. Чужую существующую установку скрипт НЕ трогает: при первом
#  признаке чужого агента он останавливается.
#
#  Что делает скрипт (9 шагов):
#    1) находит дистрибутив рядом с собой
#    2) снимает метку "скачано из интернета"
#    3) проверяет, что дом свободен (чужой агент — стоп) — ДО любых записей
#    4) ставит Git и Hermes Agent, если их нет
#    5) копирует агента (при повторном запуске — обновляет)
#    6) открывает Блокнот для ключа
#    7) проверяет, что агент отвечает
#    8) прогревает окружение (pm repair): окно поднимает бэкенд и ждёт его
#       ответа 15 секунд, а на непрогретом доме зависимости ставятся минуты
#    8b) готовит движок ДЛЯ ОКНА: окно считает движок нерабочим, если
#       `hermes --version` не уложилась в 15 с (на холодном старте так и есть),
#       и тогда уходит в доустановку, где падает на первой же стадии. Здесь
#       даём окну запас по времени (HERMES_PROBE_TIMEOUT_MS) и указываем движок
#       явно (HERMES_DESKTOP_HERMES) — ничего не скачивая
#    9) скачивает и ставит ОКНО ПРИЛОЖЕНИЯ + ярлык на рабочем столе
#
#  Пользователю не нужно ничего, кроме ключа: ни второй команды, ни выбора
#  папки, ни отдельной загрузки приложения.
# ============================================================

$ErrorActionPreference = 'Stop'
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
$ProgressPreference = 'SilentlyContinue'

$Here    = Split-Path -Parent $MyInvocation.MyCommand.Path
$LogFile = Join-Path $Here 'setup-log.txt'
$HomeDir = Join-Path $env:LOCALAPPDATA 'hermes'
$Marker  = Join-Path $HomeDir '.ailab-install.json'
$WorkDir = Join-Path $env:TEMP ('agent-setup-' + (Get-Random))

# Файлы, которые этот установщик кладёт в корневой профиль.
$OwnedFiles = @('SOUL.md', 'config.yaml')
$OwnedDirs  = @('skills')

# Куда ставится чип расхода Polza.ai (плагин + его скрипты):
#   * desktop-plugins\polza-limit\plugin.js   — один корень плагинов на всё приложение
#   * .polza-limits\*.py|*.cmd                — скрипты, которые плагин зовёт через shell.exec
# Отдельная папка (а не scripts профиля) — потому что корневой дом один.
$PluginDir   = Join-Path $HomeDir 'desktop-plugins\polza-limit'
$VersionDir  = Join-Path $HomeDir 'desktop-plugins\version-badge'
$HideMsgDir  = Join-Path $HomeDir 'desktop-plugins\hide-messaging'
$LimitsDir   = Join-Path $HomeDir '.polza-limits'

# Окно приложения (GUI). Это ОТДЕЛЬНЫЙ архив, он не входит в пакет агента, и
# получатель про него забывал: видел "ГОТОВО" и не понимал, где окно.
# Поэтому установщик сам скачивает, распаковывает и ставит ярлык — от
# пользователя не требуется ни второй команды, ни выбора папки.
# Ссылка без версии (releases/latest/download/...): не устареет при выпуске новой.
$AppUrl = 'https://github.com/AILABSCRUME/AI_Lab_SCRUME_Co/releases/latest/download/app-setup.zip'
# Та же сборка по неизменяемой ссылке с версией — на случай, если обновление
# релиза сломает latest ИЛИ админ отключил редирект. Пустая строка допустима.
# ВАЖНО: версия здесь должна существовать на GitHub. Стояла v1.3.6 — тот релиз
# удалили, ссылка стала 404, и сборщик пакета справедливо отказался собирать
# релиз с мёртвой ссылкой. Держим ссылку на ЖИВОЙ релиз и проверяем её
# сборщиком (build-package.ps1 проверяет все github-ссылки из setup.ps1).
$AppUrlFallback = 'https://github.com/AILABSCRUME/AI_Lab_SCRUME_Co/releases/download/v1.4.0/app-setup.zip'
$AppDir = Join-Path $env:LOCALAPPDATA 'AILabSCRUME\App'

function Line { param([string]$m = '') Write-Host $m }
function Step { param([string]$m) Line ''; Line ('=== ' + $m) }
function Ok   { param([string]$m) Write-Host ('  [ok] ' + $m) -ForegroundColor Green }
function Warn { param([string]$m) Write-Host ('  [!]  ' + $m) -ForegroundColor Yellow }
function Fail {
    param([string]$m)
    Line ''
    Write-Host ('[ОШИБКА] ' + $m) -ForegroundColor Red
    Line ''
    exit 1
}

# Признаки РАБОТАЮЩЕГО чужого агента. Наличие config.yaml, .env и skills/
# сюда НЕ входит: их создаёт сам официальный установщик Hermes, и на чистой
# машине они появляются сразу после установки — иначе установщик блокировал
# бы сам себя (проверено на живом прогоне 15.10.2026).
function Get-ForeignSigns {
    param([string]$Dir)
    $signs = @()
    if (-not (Test-Path $Dir)) { return $signs }

    # Персона: SOUL.md, поставленный Hermes, содержит стоковый текст.
    $soul = Join-Path $Dir 'SOUL.md'
    if (Test-Path $soul) {
        $t = Get-Content -Raw -Path $soul -ErrorAction SilentlyContinue
        if ($t -and ($t -notmatch 'You are Hermes Agent, built by Nous Research')) {
            $signs += 'SOUL.md — персона другого агента'
        }
    }

    # Память и переписка: реальные следы работы.
    foreach ($d in @('memories', 'sessions')) {
        $p = Join-Path $Dir $d
        if (Test-Path $p) {
            $n = @(Get-ChildItem -Path $p -File -ErrorAction SilentlyContinue).Count
            if ($n -gt 0) { $signs += ($d + ' — ' + $n + ' файлов') }
        }
    }

    if (Test-Path (Join-Path $Dir 'cron\jobs.json')) { $signs += 'cron — задачи по расписанию' }
    if (Test-Path (Join-Path $Dir 'auth.json'))     { $signs += 'auth.json — входы в сервисы' }

    # .env с вписанным ключом = агент настроен и работал.
    $envf = Join-Path $Dir '.env'
    if (Test-Path $envf) {
        if (Select-String -Path $envf -Pattern '^[A-Za-z_][A-Za-z0-9_]*_API_KEY=\S+' -Quiet -ErrorAction SilentlyContinue) {
            $signs += '.env — вписан API-ключ'
        }
    }

    # Именованные профили = кто-то уже настраивал Hermes под себя.
    $prof = Join-Path $Dir 'profiles'
    if (Test-Path $prof) {
        $named = @(Get-ChildItem -Path $prof -Directory -ErrorAction SilentlyContinue |
                   Where-Object { $_.Name -ne '.deleted' })
        if ($named.Count -gt 0) {
            $signs += ('профили: ' + (($named | ForEach-Object { $_.Name }) -join ', '))
        }
    }

    return $signs
}

try { Start-Transcript -Path $LogFile -Force | Out-Null } catch { Warn ('протокол в файл не пишется: ' + $LogFile) }

Line ''
Line '  УСТАНОВКА АГЕНТА AILAB SCRUME AGENT'
Line '  ---------------------------------------------'
Line ('  Папка: ' + $Here)

try {
    # ---------- 1. дистрибутив ----------
    Step '1/9  Ищу дистрибутив агента'
    # Версию сравниваем ЧИСЛАМИ, а не строками: при строковом сравнении
    # '1.3.8' больше '1.3.10' (символ '8' > '1'), и на десятом минорном
    # выпуске скрипт взял бы СТАРЫЙ бандл. Перебор явный: `Sort-Object`
    # со скриптблоком В ПЕРЕМЕННОЙ ранжирует неверно (проверено —
    # на наборе 1.3.2/1.3.8/1.3.9/1.3.10 выбрал 1.3.8).
    function Get-NewestBundle {
        param([string]$Dir, [string]$Pattern)
        $best = $null; $bestRank = -1
        foreach ($f in @(Get-ChildItem -Path $Dir -Filter $Pattern -File -ErrorAction SilentlyContinue)) {
            $m = [regex]::Match($f.Name, '(\d+)[._-](\d+)[._-](\d+)')
            $rank = if ($m.Success) {
                ([int]$m.Groups[1].Value) * 1000000 + ([int]$m.Groups[2].Value) * 1000 + [int]$m.Groups[3].Value
            } else { 0 }
            if ($rank -gt $bestRank) { $bestRank = $rank; $best = $f }
        }
        return $best
    }
    $Bundle = Get-NewestBundle $Here 'ailab-*.bundle'
    if (-not $Bundle) { $Bundle = Get-NewestBundle $Here '*.bundle' }
    if (-not $Bundle) {
        Fail 'Рядом со скриптом нет файла *.bundle. Положите setup.cmd и файл ailab-<версия>.bundle в одну папку.'
    }
    Ok ('найден: ' + $Bundle.Name + '  (' + [math]::Round($Bundle.Length / 1MB, 2) + ' МБ)')

    # ---------- 2. метка "скачано из интернета" ----------
    Step '2/9  Проверяю файлы'
    Get-ChildItem -Path $Here -Recurse -File -ErrorAction SilentlyContinue |
        ForEach-Object { try { Unblock-File -Path $_.FullName -ErrorAction Stop } catch {} }
    Ok 'метка "из интернета" снята (Windows может спросить подтверждение один раз)'

    # ---------- 3. снимок дома ДО любых записей ----------
    # Снимаем ПЕРВЫМ делом, до git и до движка: официальный установщик сам создаёт
    # config.yaml, .env и skills/ с 58 штатными скиллами, и после него
    # отличить свежую установку от работавшего агента уже нельзя.
    # Маркер нашего выпуска. Агент раньше назывался Dionis, поэтому старый файл
    # .dionis-install.json — тоже наш след: по нему установка понимает, что это
    # ОБНОВЛЕНИЕ, а не чужая машина.
    $markerData = $null
    foreach ($mk in @($Marker, (Join-Path $HomeDir '.dionis-install.json'))) {
        if ($markerData) { break }
        if (Test-Path $mk) {
            try { $markerData = Get-Content -Raw -Path $mk | ConvertFrom-Json } catch { $markerData = $null }
        }
    }
    $signsBefore  = @(Get-ForeignSigns $HomeDir)
    $hermesBefore = [bool](Get-Command hermes -ErrorAction SilentlyContinue) -or
                    (Test-Path (Join-Path $HomeDir 'bin\hermes.cmd'))

    # ---------- 6. защита чужих данных ----------
    Step '3/9  Проверяю, свободна ли установка'
    Line ('  корневой профиль: ' + $HomeDir)

    if ($markerData) {
        Ok ('здесь уже стоит AILab SCRUME Agent v' + $markerData.version + ' — обновляю его')
    } elseif ($signsBefore.Count -gt 0) {
        Line ''
        Write-Host '  На этой машине уже работал другой агент Hermes:' -ForegroundColor Yellow
        foreach ($f in $signsBefore) { Write-Host ('     - ' + $f) -ForegroundColor Yellow }
        Line ''
        Fail @'
Установка остановлена, чтобы НЕ ПОТЕРЯТЬ существующие данные.
Ничего не изменено и не установлено: проверка идёт до первой записи,
ваши настройки, ключи, память и переписка на месте.

Этот комплект ставит единственного агента AILab SCRUME Agent. Если на машине уже
работал другой агент, обратитесь к тому, кто передал вам архив:
нужна версия с отдельным профилем.
'@
    } elseif ($hermesBefore) {
        Ok 'Hermes стоял, но не настроен — ставлю поверх, ваших данных в нём нет'
    } else {
        Ok 'установка свободна — чужого агента не найдено'
    }


    # ---------- 3. git ----------
    # Git нужен и нам (распаковка дистрибутива), и официальному установщику
    # Hermes (он клонирует репозиторий). Ставим ЕГО ЖЕ артефактом и РОВНО в то
    # место, где официальный установщик его ищет:
    #     <дом>\tools\git-2.53.0+3-win32-x64\cmd\git.exe
    # Тогда его стадия prerequisites видит git и НИЧЕГО не качает.
    #
    # Почему не winget: он требует App Installer и ходит на cdn.winget.microsoft.com —
    # за корпоративным VPN этот путь отваливается молча, и установка умирала на Git.
    # Прямая ссылка GitHub проверена: 58 МБ, SHA совпадает, отдаёт 200.
    $GitPinVer  = '2.53.0+3'
    $GitPinSha  = 'b365da794b1d2225eb24d5f5e09ef7792cfd5fa26c3a3586210280c80dff3a2a'
    $GitPinUrl  = 'https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.3/PortableGit-2.53.0.3-64-bit.7z.exe'
    $GitDir     = Join-Path $HomeDir ('tools\git-' + $GitPinVer + '-win32-x64')
    $GitExe     = Join-Path $GitDir 'cmd\git.exe'

    function Add-StagedGitToPath {
        if (Test-Path $GitExe) {
            $env:PATH = (Join-Path $GitDir 'cmd') + ';' + (Join-Path $GitDir 'usr\bin') + ';' + $env:PATH
            return $true
        }
        return $false
    }

    if (Get-Command git -ErrorAction SilentlyContinue) {
        Ok ('git: ' + (git --version))
    } elseif (Add-StagedGitToPath) {
        Ok ('git (переносимый, уже был): ' + (git --version))
    } else {
        Line '  git не найден — кладу переносимый (58 МБ, скачивается с GitHub)...'
        $gitOk = $false
        try {
            $tmpGit = Join-Path $env:TEMP ('ailab-git-' + (Get-Random))
            New-Item -ItemType Directory -Path $tmpGit -Force | Out-Null
            $sfx = Join-Path $tmpGit 'portable-git.7z.exe'
            Invoke-WebRequest -Uri $GitPinUrl -OutFile $sfx -UseBasicParsing -ErrorAction Stop
            $gotSha = (Get-FileHash -Path $sfx -Algorithm SHA256).Hash.ToLowerInvariant()
            if ($gotSha -ne $GitPinSha) {
                Warn ('скачался не тот файл (SHA ' + $gotSha.Substring(0,12) + ') — пропускаю')
            } else {
                Ok ('скачано: ' + [math]::Round((Get-Item $sfx).Length / 1MB, 1) + ' МБ, SHA сошёлся')
                $unp = Join-Path $tmpGit 'unpacked'
                New-Item -ItemType Directory -Path $unp -Force | Out-Null
                Unblock-File -Path $sfx -ErrorAction SilentlyContinue
                # Self-extracting 7z: GUI-подсистема, поэтому через Process и с ожиданием.
                $proc = [System.Diagnostics.Process]::Start($sfx, ('-o"' + $unp + '" -y'))
                if (-not $proc.WaitForExit(600000)) {
                    try { $proc.Kill() } catch {}
                    Warn 'распаковка git не уложилась в 10 минут — пропускаю'
                } elseif (-not (Test-Path (Join-Path $unp 'cmd\git.exe'))) {
                    Warn 'в архиве git нет cmd\git.exe — пропускаю'
                } else {
                    if (Test-Path $GitDir) { Remove-Item -Recurse -Force $GitDir -ErrorAction SilentlyContinue }
                    New-Item -ItemType Directory -Path (Split-Path -Parent $GitDir) -Force | Out-Null
                    Move-Item $unp $GitDir
                    $gitOk = Add-StagedGitToPath
                }
            }
            Remove-Item $tmpGit -Recurse -Force -ErrorAction SilentlyContinue
        } catch {
            Warn ('переносимый git не поставился: ' + $_.Exception.Message)
        }
        if ($gitOk) { Ok ('git: ' + (git --version)) }
        else {
            Warn 'Git поставить не удалось. Установка продолжится: у официального'
            Line '  установщика Hermes есть свой запасной путь. Если он упадёт на стадии'
            Line '  prerequisites — установите Git for Windows: https://git-scm.com/download/win'
            Line '  и запустите setup.cmd снова.'
        }
    }

    # ---------- 5. Hermes Agent ----------
    Step '4/9  Проверяю Hermes Agent'

    function Resolve-Hermes {
        $c = Get-Command hermes -ErrorAction SilentlyContinue
        if ($c) { return $c.Source }
        $p = Join-Path $HomeDir 'bin\hermes.cmd'
        if (Test-Path $p) { return $p }
        return $null
    }

    # Запускается ли движок на самом деле. Пустой вывод = НЕ запускается:
    # лаунчер ссылается на питон абсолютным путём, и если пакет python не
    # доехал, падает мгновенно с 'Fatal error in launcher: Unable to create
    # process'. Такой движок не годен ни для окна, ни для `pm repair`.
    function Test-EngineRuns {
        param([string]$Path)
        if (-not $Path) { return '' }
        $v = ''
        try { $v = ((& $Path --version 2>&1) | Out-String).Trim() } catch { $v = '' }
        # Битый лаунчер печатает ТЕКСТ ОШИБКИ, а не пустоту:
        #   Fatal error in launcher: Unable to create process using
        #   '"<дом>\tools\python-...\python.exe" -I "..."'
        # Проверять только на пустоту мало — ошибка прошла бы как успех.
        if ($v -match '(?i)Fatal error in launcher|Unable to create process|не удается найти|cannot find the file') {
            return ''
        }
        # Отдаём только первую строку и только если она похожа на версию.
        $first = ($v -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -First 1)
        if (-not $first) { return '' }
        if ($first -match '(?i)Hermes Agent|v\d+\.\d+') { return $first.Trim() }
        return ''
    }

    # Пакеты в <дом>\tools не читаются пользователем. Признак: папка пакета
    # существует, но перечислить её содержимое нельзя — `Get-ChildItem` тихо
    # отдаёт 0 элементов. Так выглядит дом, где пакеты распакованы под ДРУГОЙ
    # учётной записью (обычно установку запускали «от имени администратора»):
    # `Test-Path` по самой папке даёт True, а файлов внутри не видно.
    # Внешне это ровно «движок не запускается»: лаунчер ссылается на
    # python.exe абсолютным путём и падает. Лечится не сетью, а правами.
    function Test-PackageAccess {
        param([string]$Root)
        if (-not $Root) { return '' }
        if (-not [IO.Directory]::Exists($Root)) { return '' }
        $dirs = @()
        try { $dirs = @(Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction Stop) } catch { return '' }
        $denied = @()
        foreach ($d in $dirs) {
            # Точная проверка: НЕЧИТАЕМАЯ папка бросает UnauthorizedAccessException,
            # а просто ПУСТАЯ — нет. Разница важна: «пустой пакет» и «нет прав» —
            # разные причины и разные советы пользователю.
            try {
                $null = Get-ChildItem -LiteralPath $d.FullName -Force -ErrorAction Stop
            } catch [System.UnauthorizedAccessException] {
                $denied += $d.Name
            } catch { }
        }
        if ($denied.Count -eq 0) { return '' }
        return ($denied -join ', ')
    }

    # Повтор этапа, пока проверка не пройдёт. Нужен потому, что сеть у
    # получателя нестабильна: та же загрузка встаёт по таймауту, а со второго
    # раза проходит. Возвращает $true, если проверка прошла.
    function Invoke-WithRetry {
        param([string]$Title, [scriptblock]$Action, [scriptblock]$Check,
              [int]$Attempts = 3, [int]$PauseSec = 5)
        for ($i = 1; $i -le $Attempts; $i++) {
            try { if (& $Check) { return $true } } catch { }
            try { & $Action } catch { Warn ($Title + ': ' + $_.Exception.Message) }
            try { if (& $Check) { return $true } } catch { }
            if ($i -lt $Attempts) {
                Line ('  ' + $Title + ' — не прошло (попытка ' + $i + ' из ' + $Attempts + '), повторяю через ' + $PauseSec + ' с...')
                Start-Sleep -Seconds $PauseSec
            }
        }
        try { return (& $Check) } catch { return $false }
    }

    # Доустановка движка официальной лестницей. Она же добирает пакет python
    # и зависимости — ровно то, что осталось от оборванного запуска. Дом
    # задаём явно: иначе движок уедет туда, куда смотрит чужой HERMES_HOME.
    function Invoke-OfficialLadder {
        $prev = $env:HERMES_HOME
        $env:HERMES_HOME = $HomeDir
        try {
            $installer = [scriptblock]::Create((Invoke-RestMethod 'https://hermes-agent.nousresearch.com/install.ps1'))
            & $installer -NonInteractive
        } catch {
            Warn ('официальная доустановка не удалась: ' + $_.Exception.Message)
        } finally {
            if ($null -eq $prev) { Remove-Item Env:\HERMES_HOME -ErrorAction SilentlyContinue }
            else { $env:HERMES_HOME = $prev }
        }
        $env:PATH = [Environment]::GetEnvironmentVariable('PATH', 'User') + ';' +
                    [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + $env:PATH
        return (Resolve-Hermes)
    }

    $Hermes = Resolve-Hermes
    # Пустой вывод `--version` значит, что лаунчер НЕ запускается. Типичная
    # причина: не доехал пакет python, на который лаунчер ссылается
    # АБСОЛЮТНЫМ путём (<дом>\tools\python-<версия>-win32-x64\python.exe).
    # Тогда `hermes` падает мгновенно с 'Fatal error in launcher: Unable to
    # create process', и НИ `pm repair`, ни окно запуститься не могут.
    # Раньше это считалось «Hermes установлен» и установка врала про успех.
    $hermesVer = Test-EngineRuns $Hermes
    if (-not $hermesVer) {
        # Движка нет ИЛИ он есть, но не запускается. В обоих случаях лечится
        # официальной лестницей в тот же дом: она доставит и движок, и пакет
        # python, и зависимости. Повторяем — сеть у получателя рвётся по таймауту,
        # и со второй-третьей попытки та же загрузка обычно проходит.
        if ($Hermes) {
            Warn 'движок найден, но НЕ запускается (проба --version пуста)'
            Line '  Обычная причина — от прошлого запуска не доехал пакет python:'
            Line '  лаунчер ссылается на него и падает, унося с собой и `pm repair`.'
        } else {
            Warn 'Hermes Agent не установлен — ставлю. Это несколько минут, окно не закрывайте.'
        }
        Line '  Доставляю движок официальным установщиком в тот же дом...'

        $ok = Invoke-WithRetry 'Доустановка движка' {
            $script:Hermes = Invoke-OfficialLadder
        } {
            (Test-EngineRuns $script:Hermes) -ne ''
        } 3 10

        $Hermes = $script:Hermes
        $hermesVer = Test-EngineRuns $Hermes
        if (-not $ok -or -not $hermesVer) {
            $pyDir = Join-Path $HomeDir 'tools'
            Warn 'движок не запускается даже после повторов.'
            # Сначала исключаем права: этот случай лечится НЕ повтором установки,
            # и совет про VPN уводит в сторону. Признак — пакеты есть, но внутрь
            # не войти (см. Test-PackageAccess).
            $locked = Test-PackageAccess $pyDir
            if ($locked) {
                Line ''
                Line '  ПРИЧИНА: нет доступа к пакетам в папке:'
                Line ('     ' + $pyDir)
                Line ('  Не читаются: ' + $locked)
                Line ''
                Line '  Папки созданы под другой учётной записью — обычно установку'
                Line '  запускали «от имени администратора». Сеть тут ни при чём,'
                Line '  повторный запуск setup.cmd этого не исправит.'
                Line ''
                Line '  ЛЕЧЕНИЕ: рядом с setup.cmd лежит файл FIX.cmd. Нажмите на него'
                Line '  ПРАВОЙ кнопкой -> «Запуск от имени администратора», дождитесь'
                Line '  окончания, затем запустите setup.cmd ОБЫЧНЫМ пользователем.'
                Line '  (Ключ, память и переписка при починке не трогаются.)'
                Fail 'нет доступа к пакетам движка — установка остановлена.'
            }
            Line ('  Проверьте, что на месте пакет python в: ' + $pyDir)
            Line '  Чаще всего это сеть: VPN или прокси обрывают загрузки.'
            Line '  Выключите VPN и запустите setup.cmd снова — он продолжит'
            Line '  с места обрыва (загрузки возобновляемые, заново не качает).'
            Fail 'движок не запускается — установка остановлена, чтобы не выдать нерабочего агента.'
        }
        Ok 'движок готов'
    }
    Ok ('Hermes: ' + (($hermesVer -split "`n")[0]))

    # ---------- 6. распаковка и установка ----------
    Step '5/9  Устанавливаю агента'
    if (Test-Path $WorkDir) { Remove-Item -Recurse -Force $WorkDir -ErrorAction SilentlyContinue }
    New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
    $Dist = Join-Path $WorkDir 'dist'

    Line '  распаковываю дистрибутив...'
    $cloneOut = (& git clone --quiet $Bundle.FullName $Dist 2>&1 | Out-String)
    if (-not (Test-Path (Join-Path $Dist 'distribution.yaml'))) {
        Line $cloneOut
        Fail 'Не удалось распаковать дистрибутив (git clone).'
    }

    # .git убираем: на Windows он ставит файлам атрибут "только чтение"
    $dotGit = Join-Path $Dist '.git'
    if (Test-Path $dotGit) { Remove-Item -Recurse -Force $dotGit -ErrorAction SilentlyContinue }

    $ver = (Select-String -Path (Join-Path $Dist 'distribution.yaml') -Pattern '^version:\s*(.+)$').Matches[0].Groups[1].Value.Trim()

    # Копируем доставшийся агенту минимум в корневой профиль. Файлы пользователя
    # (.env, memories, sessions, cron, logs, auth.json) не трогаем никогда.
    if (-not (Test-Path $HomeDir)) { New-Item -ItemType Directory -Path $HomeDir -Force | Out-Null }

    $copied = @()
    foreach ($f in $OwnedFiles) {
        $src = Join-Path $Dist $f
        if (Test-Path $src) {
            Copy-Item $src (Join-Path $HomeDir $f) -Force
            $copied += $f
        }
    }
    foreach ($d in $OwnedDirs) {
        $src = Join-Path $Dist $d
        if (Test-Path $src) {
            $dst = Join-Path $HomeDir $d
            if (Test-Path $dst) { Remove-Item -Recurse -Force $dst -ErrorAction SilentlyContinue }
            Copy-Item $src $dst -Recurse -Force
            $copied += ($d + '\')
        }
    }
    # --- чип расхода Polza.ai: плагин + скрипты (необязательный компонент) ---
    $hasPlugin = Test-Path (Join-Path $Dist 'desktop-plugins\polza-limit\plugin.js')
    if ($hasPlugin) {
        New-Item -ItemType Directory -Path $PluginDir -Force | Out-Null
        Copy-Item (Join-Path $Dist 'desktop-plugins\polza-limit\plugin.js') $PluginDir -Force
        if (Test-Path (Join-Path $Dist 'scripts\polza-limits')) {
            New-Item -ItemType Directory -Path $LimitsDir -Force | Out-Null
            Copy-Item (Join-Path $Dist 'scripts\polza-limits\*') $LimitsDir -Recurse -Force
        }
        $copied += 'плагин чипа расхода'
    }
    # --- номер сборки в заголовке окна (необязательный компонент) ---
    # Строка состояния внизу скрыта в режиме Simple, а слоты заголовка
    # рендерятся всегда — поэтому версия показывается именно там.
    $hasBadge = Test-Path (Join-Path $Dist 'desktop-plugins\version-badge\plugin.js')
    if ($hasBadge) {
        New-Item -ItemType Directory -Path $VersionDir -Force | Out-Null
        Copy-Item (Join-Path $Dist 'desktop-plugins\version-badge\plugin.js') $VersionDir -Force
        $copied += 'плагин с номером сборки'
    }
    # --- «Сообщения» убраны из боковой навигации (необязательный компонент) ---
    # Мессенджеры у получателя не подключены, а вкладка ведёт в настройку
    # Telegram/Slack/Discord и шлюз сообщений. Скрывается только СТРОКА навигации —
    # возможность остаётся, клавиша и прямой адрес работают.
    $hasHide = Test-Path (Join-Path $Dist 'desktop-plugins\hide-messaging\plugin.js')
    if ($hasHide) {
        New-Item -ItemType Directory -Path $HideMsgDir -Force | Out-Null
        Copy-Item (Join-Path $Dist 'desktop-plugins\hide-messaging\plugin.js') $HideMsgDir -Force
        $copied += 'вкладка «Сообщения» скрыта'
    }

    Ok ('скопировано: ' + ($copied -join ', '))

    # Свой след: по нему следующая установка поймёт, что это ОБНОВЛЕНИЕ.
    $stamp = [ordered]@{
        agent      = 'ailab_scrume_agent'
        version    = $ver
        installed  = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ssK')
        source     = $Bundle.Name
    } | ConvertTo-Json
    [System.IO.File]::WriteAllText($Marker, $stamp, [System.Text.UTF8Encoding]::new($false))
    # Прежнее имя маркера чистим только при обновлении поверх Dionis
    $legacyMarker = Join-Path $HomeDir '.dionis-install.json'
    if ((Test-Path $legacyMarker) -and ($legacyMarker -ne $Marker)) { Remove-Item -Force $legacyMarker -ErrorAction SilentlyContinue }

    # ---------- 7. ключ ----------
    Step '6/9  Ключ API'
    $EnvFile    = Join-Path $HomeDir '.env'
    $EnvExample = Join-Path $HomeDir '.env.EXAMPLE'
    $KeyPattern = 'HERMES_CUSTOM_POLZA_AI_API_KEY=(\S+)'

    $haveKey = $false
    if (Test-Path $EnvFile) {
        if (Select-String -Path $EnvFile -Pattern $KeyPattern -Quiet -ErrorAction SilentlyContinue) { $haveKey = $true }
    }

    if ($haveKey) {
        Ok 'ключ уже вписан — шаг пропускаю'
    } else {
        if (Test-Path $EnvExample) { Copy-Item $EnvExample $EnvFile -Force }
        elseif (-not (Test-Path $EnvFile)) { New-Item -ItemType File -Path $EnvFile -Force | Out-Null }
        if (-not (Select-String -Path $EnvFile -Pattern 'HERMES_CUSTOM_POLZA_AI_API_KEY' -Quiet)) {
            Add-Content -Path $EnvFile -Value 'HERMES_CUSTOM_POLZA_AI_API_KEY='
        }

        Line ''
        Line '  Сейчас откроется Блокнот. Впишите полученный ключ после знака "="'
        Line '  без кавычек и пробелов, сохраните (Ctrl+S) и закройте Блокнот:'
        Line ''
        Line '        HERMES_CUSTOM_POLZA_AI_API_KEY=ваш_ключ'
        Line ''
        Read-Host '  Нажмите Enter, чтобы открыть Блокнот'
        try { Start-Process -FilePath notepad.exe -ArgumentList $EnvFile -Wait }
        catch { Warn ('Блокнот не открылся — впишите ключ вручную в файл: ' + $EnvFile) }

        if (Select-String -Path $EnvFile -Pattern $KeyPattern -Quiet -ErrorAction SilentlyContinue) { Ok 'ключ вписан' }
        else { Warn ('ключ пока пустой. Впишите его позже в файл: ' + $EnvFile) }
    }

    # ---------- 8. проверка ----------
    Step '7/9  Проверка'
    $keyOk = (Test-Path $EnvFile) -and (Select-String -Path $EnvFile -Pattern $KeyPattern -Quiet -ErrorAction SilentlyContinue)
    if ($keyOk) {
        # Проверяем ИМЕННО установленный агент: без явного HERMES_HOME команда
        # hermes из PATH может указывать на другую установку на этой машине.
        $prevHome = $env:HERMES_HOME
        $prevEap  = $ErrorActionPreference
        $env:HERMES_HOME = $HomeDir
        # Первый запуск в новом доме печатает в stderr подготовку зависимостей —
        # при ErrorActionPreference=Stop это выглядит как ошибка и рушит проверку.
        $ErrorActionPreference = 'Continue'

        # Берём ТОЛЬКО stdout. Если слить stderr (2>&1), в текст проверки попадает
        # служебный вывод, у которого в пути есть имя папки распаковки
        # распаковки), а -match в PowerShell регистронезависим — тогда ошибка
        # проходит как "ответ агента". Проверяли это на живом прогоне.
        # Строгий поиск имени: \b...\b обязателен, иначе 'AILab' находится ВНУТРИ
        # слова 'av|ailab|le' ('temporarily unavailable') — PowerShell -match
        # регистронезависим, и сообщение о сбое проходило как ответ агента.
        $NamePat = '\bAILab\b|\bSCRUME\b|помощник'
        Line '  спрашиваю агента, кто он...'

        # ЖЁСТКИЙ таймаут обязателен. При недоступном провайдере агент уходит в
        # лестницу авто-восстановления (agent.auto_recovery_cycles = 5, паузы
        # 15/30/60/60/60 с) и молчит минутами: обычный вызов (& hermes ...) висел
        # без конца и установка не заканчивалась. Start-Process + WaitForExit(ms)
        # возвращает управление всегда. Вывод пишем в файлы и читаем ПОСЛЕ
        # завершения процесса: при живом процессе редирект буферизует текст.
        $script:ProbeRaw = ''
        function Get-AgentAnswer {
            param([int]$TimeoutMs = 55000)
            $tmpBase = Join-Path ([System.IO.Path]::GetTempPath()) ('ailab-probe-' + [guid]::NewGuid().ToString('N'))
            $outF = $tmpBase + '.out'; $errF = $tmpBase + '.err'
            $raw = ''
            try {
                $p = Start-Process -FilePath $Hermes -NoNewWindow -PassThru -WorkingDirectory $HomeDir `
                     -ArgumentList 'chat -Q --oneshot -q "Как тебя зовут? Ответь одной строкой."' `
                     -RedirectStandardOutput $outF -RedirectStandardError $errF
                if (-not $p.WaitForExit($TimeoutMs)) { try { $p.Kill() } catch {}; Start-Sleep -Milliseconds 600 }
                foreach ($f in @($outF, $errF)) {
                    if (Test-Path $f) { $raw += ((Get-Content $f -Raw -ErrorAction SilentlyContinue) + "`n") }
                }
            } finally {
                Remove-Item $outF -Force -ErrorAction SilentlyContinue
                Remove-Item $errF -Force -ErrorAction SilentlyContinue
            }
            $script:ProbeRaw = $raw
            return ($raw -split "`r?`n" |
                    Where-Object { $_ -notmatch '\.ps1' -and $_ -notmatch 'знак\s*:' -and
                                   $_ -notmatch 'CategoryInfo|FullyQualifiedErrorId|Exception' })
        }

        try {
            $lines = Get-AgentAnswer
            $hit = ($lines | Where-Object { $_ -match $NamePat } | Select-Object -First 1)
            if (-not $hit) {
                # Свежий дом готовит зависимости при первом запуске — даём ему
                # закончить и пробуем ещё раз, прежде чем считать проверку неудачной.
                Line '  первый запуск: жду подготовки зависимостей и повторяю...'
                Start-Sleep -Seconds 20
                $lines = Get-AgentAnswer
                $hit = ($lines | Where-Object { $_ -match $NamePat } | Select-Object -First 1)
            }
        } finally {
            $ErrorActionPreference = $prevEap
            if ($null -eq $prevHome) { Remove-Item Env:\HERMES_HOME -ErrorAction SilentlyContinue }
            else { $env:HERMES_HOME = $prevHome }
        }

        if ($hit) {
            Ok ('агент отвечает: ' + $hit.Trim())
        } elseif ($script:ProbeRaw -match '401|403|Неверные учётные данные|rejected your API key|invalid[ _]api[ _]key') {
            # Ключ отклонён — ответ приходит быстро, это надёжный признак.
            Warn 'агент установлен, но провайдер отклонил API-ключ.'
            Line ('  Проверьте ключ в файле: ' + $EnvFile)
        } elseif ($script:ProbeRaw -match 'temporarily unavailable|retrying automatically|cycle \d+/|auto-recovery') {
            Warn 'агент установлен, но провайдер ответил "временно недоступен".'
            Line '  Ключ и установка в порядке — запустите приложение, всё готово.'
        } elseif ($script:ProbeRaw -match 'preparing dependencies|No module named|Installing Python') {
            Warn 'агент ещё готовит окружение (первый запуск).'
            Line '  Запустите setup.cmd ещё раз через минуту — установка не пострадала.'
        } elseif (-not $script:ProbeRaw.Trim()) {
            # Агент не ответил за отведённое время и ничего не напечатал: так ведёт
            # себя недоступный провайдер (агент молча ждёт восстановления).
            Warn 'агент не ответил за минуту — скорее всего провайдер недоступен.'
            Line '  Установка при этом в порядке: файлы и ключ на месте.'
            Line '  Запустите приложение; если не заработает — повторите setup.cmd позже.'
        } else {
            Warn 'агент не ответил про AILab SCRUME Agent — проверьте ключ и запустите setup.cmd снова.'
        }

    } else {
        Warn 'без ключа проверить агента нельзя — впишите ключ и запустите setup.cmd снова.'
    }

    # ---------- 8b. прогрев окружения ----------
    # Окно приложения поднимает `hermes serve` и ждёт его ВСЕГО 5 СЕКУНД
    # (DEFAULT_HEALTH_PROBE_TIMEOUT_MS). На непрогретом доме serve тратит
    # минуты на установку питоновских зависимостей — окно не дожидается и
    # висит в «waiting». Поэтому ставим окружение ЗАРАНЕЕ, до первого запуска.
    Step '8/9  Готовлю окружение'
    $origEap = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    $prevHome2 = $env:HERMES_HOME
    $env:HERMES_HOME = $HomeDir
    try {
        # Готовность определяем по АРТЕФАКТУ, а не по `pm doctor`: на непрогретом
        # доме doctor печатает галочки по пакетам инструментов, хотя окружение
        # для serve ещё не собрано (проверено: ✓10 при 175 с до готовности).
        #
        # ВАЖНО: одной папки `venv` НЕДОСТАТОЧНО. Её создаёт стадия venv
        # ОФИЦИАЛЬНОЙ установки — то есть ДО того, как поедут зависимости, и
        # она остаётся от оборванного запуска. На живом случае это дало
        # '[ok] окружение уже готово' при МЁРТВОМ движке (не доехал пакет
        # python) — прогрев не запускался, и установка врала про успех.
        # Поэтому спрашиваем САМ движок: `--version` должен ответить.
        $envDir = Join-Path $HomeDir 'installs'
        $ready = $false
        if (Test-Path $envDir) {
            $ready = @(Get-ChildItem -Path $envDir -Recurse -Directory -Filter 'venv' -ErrorAction SilentlyContinue).Count -gt 0
        }
        if ($ready) {
            $probe = ''
            try { $probe = ((& $Hermes --version 2>&1) | Out-String).Trim() } catch { $probe = '' }
            if (-not $probe) {
                Warn 'окружение выглядит собранным, но движок не отвечает — прогоняю прогрев заново'
                $ready = $false
            }
        }

        if ($ready) {
            Ok 'окружение уже готово'
        } else {
            Line '  ставлю зависимости Python (~60 МБ). Это один раз и занимает'
            Line '  несколько минут на медленном интернете. Ниже идёт отчёт установки:'
            Line ''

            # Вывод идёт В ПОТОК, а не копится в переменную: молчащий шаг на
            # десять минут читается как зависание (первый получатель так и сказал).
            $logFile = Join-Path $Here 'pm.log'
            # Повтор: на нестабильном канале прогрев часто срывается по таймауту.
            # Критерий успеха — движок ОТВЕЧАЕТ, а не код возврата pm.
            $warmOk = $false
            foreach ($warmTry in 1..3) {
            if ($warmOk) { break }
            if ($warmTry -gt 1) {
                Line ('  прогрев не удался (попытка ' + ($warmTry-1) + ' из 3), повторяю через 15 с...')
                Start-Sleep -Seconds 15
            }
            $proc = Start-Process -FilePath $Hermes -NoNewWindow -PassThru `
                    -ArgumentList 'pm repair' -WorkingDirectory $HomeDir `
                    -RedirectStandardOutput $logFile -RedirectStandardError ($logFile + '.err')
            # Потолок 10 минут: дальше продолжаем без прогрева. Агент работает и
            # без него — просто окно при первом запуске откроется не сразу.
            $deadline = (Get-Date).AddMinutes(10)
            while (-not $proc.HasExited -and (Get-Date) -lt $deadline) {
                Start-Sleep -Seconds 5
                if (Test-Path $logFile) {
                    $tail = Get-Content $logFile -Tail 1 -ErrorAction SilentlyContinue
                    if ($tail) { Line ('    ' + ($tail -join ' ').Trim()) }
                }
            }
            $timedOut = -not $proc.HasExited
            if ($timedOut) { try { $proc.Kill() } catch {} }

            $out = ''
            foreach ($f in @($logFile, ($logFile + '.err'))) {
                if (Test-Path $f) { $out += ((Get-Content $f -Raw -ErrorAction SilentlyContinue) + "`n") }
            }
            Remove-Item $logFile -Force -ErrorAction SilentlyContinue
            Remove-Item ($logFile + '.err') -Force -ErrorAction SilentlyContinue

            if ($timedOut) {
                Warn 'прогрев не уложился в 10 минут — продолжаю установку.'
                Line '  Агент работает и так; окно при первом запуске откроется не сразу.'
                Line '  Позже можно повторить:  hermes pm repair'
            } elseif ($out -match '(?i)\bfailed\b|\berror\b') {
                Warn 'прогрев завершился с замечаниями — это не мешает работе агента.'
                Line '  Позже можно повторить:  hermes pm repair'
            } else {
                Ok 'окружение готово'
            }
            if (Test-EngineRuns $Hermes) {
                $warmOk = $true
            } else {
                Warn 'после прогрева движок всё ещё не отвечает'
            }
            }   # конец цикла прогрева

            # Пакеты для скиллов документов: Excel, Word, PDF, PowerPoint.
            # Без них агент УМЕЕТ только ЧИТАТЬ готовые файлы (read_file разбирает
            # .xlsx/.docx/.pptx сам), а создать или изменить НЕ может: скрипты
            # скиллов делают `import openpyxl` / `docx` / `pptx` / `pypdf`, и на
            # чистом доме это ModuleNotFoundError. Проверено на живом доме:
            # xlsx, docx, pptx и pdf создаются и читаются обратно.
            #
            # Ставим в venv ДВИЖКА, а не в набортный `tools\python-*`: агент в
            # терминале видит именно venv (в нём свой PATH), у набортного питона
            # свои пакеты и он не в PATH. Через `uv pip`, потому что в venv движка
            # НЕТ pip (`No module named pip`), а uv лежит в tools после прогрева.
            # Венв может быть не один — ставим во ВСЕ найденные: на живом доме их
            # два, и `python` в PATH указывал НЕ на тот, куда поставили пакеты.
            #
            # Не критично для установки: не получилось — скажем и продолжим.
            if ($warmOk) {
                # edge-tts обновляем ОТДЕЛЬНО и принудительно: в 7.2.7 синтез речи
                # отвечает 403 (проверено на живой машине — Microsoft сменил
                # требование к токену), а в 7.3.1 работает. Без обновления голос
                # молчит, хотя библиотека установлена.
                $docPkgs = @('openpyxl', 'python-docx', 'python-pptx', 'pypdf', 'reportlab', 'pdfplumber', 'pypdfium2', 'edge-tts')
                Line ('  ставлю пакеты документов и голоса (' + ($docPkgs -join ', ') + ')...')
                try {
                    $uvExe = $null
                    $uvDir = Get-ChildItem -Path (Join-Path $HomeDir 'tools') -Directory -Filter 'uv-*' -ErrorAction SilentlyContinue |
                             Select-Object -First 1
                    if ($uvDir) {
                        $cand = Join-Path $uvDir.FullName 'uv.exe'
                        if (Test-Path $cand) { $uvExe = $cand }
                    }
                    $pyList = @()
                    foreach ($envDir in @(Get-ChildItem -Path (Join-Path $HomeDir 'installs') -Recurse -Directory -Filter 'venv' -ErrorAction SilentlyContinue)) {
                        $cand = Join-Path $envDir.FullName 'Scripts\python.exe'
                        if (Test-Path $cand) { $pyList += $cand }
                    }
                    if (-not $uvExe) {
                        Warn 'не нашёл uv — пакеты документов пропущены'
                    } elseif ($pyList.Count -eq 0) {
                        Warn 'не нашёл python движка — пакеты документов пропущены'
                    } else {
                        $allOk = $true
                        foreach ($pyForAgent in $pyList) {
                            $null = (& $uvExe pip install --python $pyForAgent @docPkgs 2>&1 | Out-String)
                            # Голос: edge-tts обязан быть >= 7.3 — на 7.2.7 синтез даёт 403.
                            $null = (& $uvExe pip install --python $pyForAgent -U edge-tts 2>&1 | Out-String)
                            # Проверяем не код возврата uv, а РЕАЛЬНЫЙ импорт в этом питоне.
                            $probePkg = (& $pyForAgent -c "import openpyxl, docx, pptx, pypdf; print('ok')" 2>&1 | Out-String).Trim()
                            if ($probePkg -ne 'ok') { $allOk = $false }
                        }
                        if ($allOk) {
                            Ok ('пакеты документов на месте (движков: ' + $pyList.Count + ')')
                        } else {
                            Warn 'часть пакетов документов не встала — скиллы Excel/Word/PDF/PowerPoint не смогут создавать файлы'
                            Line '  Позже можно повторить:  uv pip install --python <venv\Scripts\python.exe> openpyxl python-docx python-pptx pypdf reportlab pdfplumber'
                        }
                    }
                } catch {
                    Warn ('пакеты документов: ' + $_.Exception.Message)
                }
            }
            if (-not $warmOk) {
                $locked2 = Test-PackageAccess (Join-Path $HomeDir 'tools')
                if ($locked2) {
                    Warn 'прогрев не удался: нет доступа к пакетам.'
                    Line ('  Не читаются: ' + $locked2)
                    Line '  Папки созданы под другой учётной записью (установка «от администратора»).'
                    Line '  Лечение: от администратора — takeown и icacls на дом, затем setup.ps1'
                    Line '  ОБЫЧНЫМ пользователем. Подробности печатает шаг 4/9.'
                    Fail 'нет доступа к пакетам — установка не завершена.'
                }
                Warn 'прогрев не удался за три попытки.'
                Line '  Чаще всего это сеть: выключите VPN и запустите setup.cmd снова —'
                Line '  загрузки возобновляемые, заново качать не будет.'
                Fail 'окружение не готово — установка не завершена.'
            }
        }
    } catch {
        Warn ('прогрев не выполнился: ' + $_.Exception.Message)
        Line '  Агент работает и без него — просто окно откроется не сразу.'
    } finally {
        $ErrorActionPreference = $origEap
        if ($null -eq $prevHome2) { Remove-Item Env:\HERMES_HOME -ErrorAction SilentlyContinue }
        else { $env:HERMES_HOME = $prevHome2 }
    }

    # ---------- 8b. движок для окна ----------
    # Окно поднимает движок только если его проба укладывается в 15 секунд
    # (backend-probes.ts: DEFAULT_PROBE_TIMEOUT_MS). На холодном старте Windows
    # `hermes --version` идёт 13 с и больше (замерено: 13.45 с против 3.37 с на
    # тёплом) — в лимит не укладывается. Тогда окно считает движок нерабочим,
    # уходит в доустановку, качает официальный install.ps1 и падает на первой же
    # стадии, «System prerequisites» (переносимый Git), с ошибкой «Отказано в
    # доступе». Для получателя это выглядит как «Ошибка установки».
    # Лечим ПРИЧИНУ, а не следствие: даём окну запас по времени пробы и
    # указываем движок явно. Тогда доустановка не запускается вовсе.
    #
    # Git сюда НЕ скачиваем: это 55 МБ и лишняя точка отказа ради стадии,
    # которой при работающем движке не бывает. Официальный установщик поставит
    # свой Git сам, если он когда-нибудь всё же понадобится (например, при
    # штатном обновлении движка через `hermes update`).
    Step '8b/9  Готовлю движок для окна'
    try {
        # Лаунчер движка. Порядок: сначала тот, что публикует сам движок,
        # затем исторический пользовательский bin.
        $launcher = $null
        foreach ($cand in @((Join-Path $HomeDir 'hermes-agent\.hermes\bin\hermes.exe'),
                            (Join-Path $HomeDir 'hermes-agent\.hermes\bin\hermes.cmd'),
                            (Join-Path $HomeDir 'bin\hermes.exe'),
                            (Join-Path $HomeDir 'bin\hermes.cmd'))) {
            if (Test-Path $cand) { $launcher = $cand; break }
        }

        if ($launcher) {
            # Прогреваем ровно ту пробу, которую делает окно (`--version`), и меряем.
            $t0 = Get-Date
            $vOut = (& $launcher --version 2>&1 | Out-String)
            $probeSec = [int](((Get-Date) - $t0).TotalSeconds)
            Line ('  проба движка: ' + $probeSec + ' с (' + (($vOut.Trim() -split "`n")[0]) + ')')
            if ($probeSec -ge 12) {
                Warn 'проба идёт долго (холодный старт) — именно из-за этого окно уходило в доустановку'
            }

            # Движок не отвечает: ставить метку «установка завершена» НЕЛЬЗЯ.
            # Метка — это обещание окну, что движок рабочий; с мёртвым движком
            # окно просто получит нерабочий бэкенд, а получатель — «работает»,
            # которого нет. Раньше проба '0 с ()' считалась нормой.
            $engineOk = Test-EngineRuns $launcher
            if (-not $engineOk) {
                Warn 'движок не отвечает — метку завершения НЕ ставлю'
                Line '  Окно всё равно останется при своём движке (переменные ниже',
                Line '  выставлены), но установка не считается завершённой.'
                Line '  Запустите setup.cmd снова: он доставит недостающее и повторит.'
                throw 'движок не отвечает на пробу — пропускаю шаг 8b'
            }

            # (1) Запас окну: дефолтные 15 с малы для холодного старта Windows.
            # Замеры: 13.45 с на холодном движке против 3.37 с на тёплом.
            [Environment]::SetEnvironmentVariable('HERMES_PROBE_TIMEOUT_MS', '120000', 'User')
            $env:HERMES_PROBE_TIMEOUT_MS = '120000'
            # (2) Явный движок: с ним окно не делает пробу вообще — доверяет
            # значению (штатный приём для сборок-раскладок).
            [Environment]::SetEnvironmentVariable('HERMES_DESKTOP_HERMES', $launcher, 'User')
            $env:HERMES_DESKTOP_HERMES = $launcher

            # Говорим системе об изменении окружения: иначе Проводник отдаст
            # ярлыку старые переменные до следующего входа в систему.
            try {
                Add-Type -Namespace AILab -Name Env -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Auto)]
public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
'@ -ErrorAction Stop
                $res = [UIntPtr]::Zero
                [void][AILab.Env]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$res)
            } catch { }


            # (3) ГЛАВНОЕ. Приложение собрано как «bootstrap»: движка внутри него
            # нет, и при первом запуске окно ОБЯЗАНО занять дом — оно идёт в
            # официальный install.ps1 (ставит движок с GitHub, без AILab-брендинга
            # и без следов работы; у нас падало на самой первой стадии,
            # «Отказано в доступе»). Метка .hermes-bootstrap-complete говорит окну,
            # что установка уже завершена, и доустановка не запускается вообще.
            #
            # ВАЖНО: приложение признаёт метку только если в ней есть поле
            # pinnedCommit — строка из 7..40 шестнадцатеричных знаков, и НЕ из
            # одних нулей (`hasValidBootstrapMarker`). Любое другое значение
            # (например 'ailab-123') метку обесценивает: окно прочитает её и
            # всё равно уйдёт в доустановку. Поэтому берём коммит ровно так же,
            # как это делает само приложение: HEAD дерева движка; если .git нет —
            # коммит из штампа установленного приложения.
            $markerFile = Join-Path $HomeDir 'hermes-agent\.hermes-bootstrap-complete'
            $engineRoot = Join-Path $HomeDir 'hermes-agent'
            if (Test-Path $engineRoot) {
                $pinCommit = $null
                $pinBranch = 'main'
                try {
                    $head  = (& git -C $engineRoot rev-parse HEAD 2>$null | Select-Object -First 1)
                    $head  = if ($head) { $head.Trim() } else { '' }
                    if ($head -match '^[0-9a-fA-F]{7,40}$' -and $head -notmatch '^0+$') {
                        $pinCommit = $head.ToLowerInvariant()
                        $br = (& git -C $engineRoot rev-parse --abbrev-ref HEAD 2>$null | Select-Object -First 1)
                        if ($br -and $br.Trim() -and $br.Trim() -ne 'HEAD') { $pinBranch = $br.Trim() }
                    }
                } catch { }

                # Дерева .git нет — берем коммит из штампа приложения: он и так
                # описывает, из чего собран движок, и приложение само сверяется с ним.
                if (-not $pinCommit) {
                    foreach ($stampPath in @(
                        (Join-Path $AppDir 'resources\install-stamp.json'),
                        (Join-Path $AppDir 'install-stamp.json'))) {
                        if ($pinCommit) { break }
                        if (Test-Path $stampPath) {
                            try {
                                $st = Get-Content -Raw -Path $stampPath | ConvertFrom-Json
                                if ($st.commit -match '^[0-9a-fA-F]{7,40}$' -and $st.commit -notmatch '^0+$') {
                                    $pinCommit = $st.commit.ToLowerInvariant()
                                    if ($st.branch) { $pinBranch = $st.branch }
                                }
                            } catch { }
                        }
                    }
                }

                if ($pinCommit) {
                    $stampData = [ordered]@{
                        schemaVersion = 1
                        pinnedCommit  = $pinCommit
                        pinnedBranch  = $pinBranch
                        completedAt   = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
                        desktopVersion = $ver
                        source        = 'ailab-setup'
                        agent         = 'ailab_scrume_agent'
                        launcher      = $launcher
                    } | ConvertTo-Json -Depth 4
                    [System.IO.File]::WriteAllText($markerFile, $stampData, [System.Text.UTF8Encoding]::new($false))
                    Ok ('окну сказано: установка завершена (метка, коммит ' + $pinCommit.Substring(0,7) + ')')
                } else {
                    Warn 'не удалось определить коммит движка — метку завершения не поставил'
                    Line '  Окно может попробовать доустановку. Запустите setup.cmd снова.'
                }
            } else {
                Warn 'дерева hermes-agent нет — метку завершения поставить некуда'
            }

            Ok 'окну указан движок и дан запас по времени пробы'
        } else {
            Warn 'лаунчер движка не найден — окно будет искать его само'
        }
    } catch {
        # Установку не рушим: агент работает и без этого шага. Но говорим прямо.
        Warn ('подготовка движка для окна не удалась: ' + $_.Exception.Message)
        Line '  Агент при этом работает. Если окно покажет ошибку установки —'
        Line '  запустите setup.cmd ещё раз и напишите автору агента.'
    }

    # ---------- 9. окно приложения ----------
    # ОДНОЙ кнопки достаточно: агент + приложение + ярлык. Пользователь ничего
    # не выбирает и никуда не ходит. Если приложение уже стоит — только ярлык.
    Step '9/9  Ставлю окно приложения'
    $appExe = Join-Path $AppDir 'Hermes.exe'
    $appOk  = $false

    if (Test-Path $appExe) {
        Ok ('приложение уже стоит: ' + $AppDir)
        $appOk = $true
    } else {
        $zip = Join-Path $env:TEMP 'ailab-app.zip'
        Line '  скачиваю приложение (~158 МБ, это несколько минут)...'
        # Повторяем и в пределах одной попытки (две ссылки), и между попытками:
        # на нестабильном канале 158 МБ редко проходят с первого раза.
        $downloaded = $false
        foreach ($attempt in 1..3) {
            if ($downloaded) { break }
            foreach ($u in @($AppUrl, $AppUrlFallback)) {
                if ($downloaded) { break }
                if (-not $u) { continue }
                try {
                    # BITS быстрее и умеет продолжать, но на части машин недоступен —
                    # тогда обычный запрос. $ProgressPreference уже SilentlyContinue:
                    # иначе Invoke-WebRequest рисует полосу и тормозит в разы.
                    if (Test-Path $zip) { Remove-Item $zip -Force -ErrorAction SilentlyContinue }
                    Invoke-WebRequest -Uri $u -OutFile $zip -UseBasicParsing -ErrorAction Stop
                    if ((Get-Item $zip).Length -gt 20MB) { $downloaded = $true }
                } catch {
                    Warn ('не скачалось: ' + $_.Exception.Message)
                }
            }
            if (-not $downloaded -and $attempt -lt 3) {
                Line ('  приложение не скачалось (попытка ' + $attempt + ' из 3), повторяю через 10 с...')
                Start-Sleep -Seconds 10
            }
        }
        if (-not $downloaded) {
            Warn 'НЕ удалось скачать приложение после трёх попыток.'
            Line '  Агент при этом поставлен и работает.'
            Line '  Чаще всего это сеть: выключите VPN и запустите setup.cmd снова —'
            Line '  он докачает только приложение, агента ставить не будет.'
            Fail 'окно приложения не поставлено — установка не завершена.'
        } else {
            Ok ('скачано: ' + [math]::Round((Get-Item $zip).Length / 1MB, 1) + ' МБ')
            Line '  распаковываю...'
            try {
                if (Test-Path $AppDir) { Remove-Item -Recurse -Force $AppDir -ErrorAction SilentlyContinue }
                New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
                Expand-Archive -Path $zip -DestinationPath $AppDir -Force -ErrorAction Stop
                if (Test-Path $appExe) { $appOk = $true; Ok ('распаковано в ' + $AppDir) }
                else { Warn 'распаковалось, но Hermes.exe не найден — архив повреждён?' }
            } catch {
                Warn ('не удалось распаковать: ' + $_.Exception.Message)
            }
        }
        Remove-Item $zip -Force -ErrorAction SilentlyContinue
    }

    # Ярлык на рабочий стол. Ставим и когда приложение уже было — ярлыка могло не быть.
    if ($appOk) {
        $mk = Join-Path $AppDir 'make-shortcut.ps1'
        if (Test-Path $mk) {
            # Через Start-Process: скрипт сам печатает итог, ошибки не должны
            # рушить установку (разрешения на .lnk, политика выполнения).
            $mkOut = (& powershell -NoProfile -ExecutionPolicy Bypass -File $mk 2>&1 | Out-String)
            $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'AILab SCRUME Agent.lnk'
            if (Test-Path $lnk) { Ok 'ярлык «AILab SCRUME Agent» на рабочем столе' }
            else {
                Warn 'ярлык не создался. Создайте его сами — двойной клик по:'
                Line ('    ' + (Join-Path $AppDir 'make-shortcut.cmd'))
                Line $mkOut.Trim()
            }
        } else {
            # Старые сборки приложения шли без скрипта ярлыка: делаем сами.
            try {
                $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'AILab SCRUME Agent.lnk'
                $ic  = Join-Path $AppDir 'resources\icon.ico'
                $sh  = New-Object -ComObject WScript.Shell
                $sc  = $sh.CreateShortcut($lnk)
                $sc.TargetPath = $appExe
                $sc.WorkingDirectory = $AppDir
                if (Test-Path $ic) { $sc.IconLocation = $ic }
                $sc.Save()
                if (Test-Path $lnk) { Ok 'ярлык «AILab SCRUME Agent» на рабочем столе' }
            } catch { Warn ('ярлык не создался: ' + $_.Exception.Message) }
        }
    }

    # Последняя проверка перед «ГОТОВО»: движок должен реально отвечать.
    # Без неё установка рапортовала об успехе при мёртвом движке (не доехал
    # пакет python), и получатель узнавал правду только при запуске окна.
    $finalVer = ''
    try { $finalVer = Test-EngineRuns $Hermes } catch { $finalVer = '' }
    if (-not $finalVer) {
        $Hermes2 = Invoke-OfficialLadder
        $finalVer = Test-EngineRuns $Hermes2
    }

    Line ''
    Line '  ---------------------------------------------'
    if ($finalVer) {
        Line '  ГОТОВО.'
    } else {
        Line '  УСТАНОВКА НЕ ЗАВЕРШЕНА.'
        Line ''
        Line '  Движок Hermes не запускается, поэтому агент работать не будет.'
        # Права проверяем ПЕРВЫМИ: это состояние повторный запуск не лечит,
        # и совет «попробуйте ещё раз»/«это сеть» уводит в сторону.
        $locked3 = Test-PackageAccess (Join-Path $HomeDir 'tools')
        if ($locked3) {
            Line ''
            Line '  ПРИЧИНА: нет доступа к пакетам движка. Не читаются:'
            Line ('     ' + $locked3)
            Line ''
            Line '  Папки созданы под другой учётной записью — установку запускали'
            Line '  «от имени администратора». Сеть тут ни при чём.'
            Line ''
            Line '  ЛЕЧЕНИЕ: запустите от администратора файл FIX.cmd из папки, куда'
            Line '  распакован архив (двойной клик по нему — нет, правой кнопкой:'
            Line '  «Запуск от имени администратора»). Дождитесь окончания, затем'
            Line '  запустите setup.cmd ОБЫЧНЫМ пользователем.'
        } else {
            Line '  Скорее всего от прошлого запуска не доехали зависимости — это сеть.'
            Line '  Запустите setup.cmd ещё раз: он продолжит с места обрыва.'
        }
        Line ''
        Line '  Если повторится — пришлите автору файл протокола:'
        Line ('     ' + $LogFile)
        exit 1
    }
    Line ''
    if ($appOk) {
        Line '  Запуск: ярлык «AILab SCRUME Agent» на рабочем столе'
        Line ('  или файл: ' + $appExe)
    } else {
        Warn 'окно приложения ещё не поставлено — см. сообщение выше.'
        Line '  Агент работает: в чате через командную строку он отвечает.'
    }
    Line ''
    Line '  Персона, имя владельца, тон — файл:'
    Line ('     ' + (Join-Path $HomeDir 'SOUL.md'))
    Line ''
    Line ('  Ключ: ' + $EnvFile)
    Line ('  Протокол установки: ' + $LogFile)
    Line ''

    exit 0
}
finally {
    try { Stop-Transcript | Out-Null } catch {}
    if (Test-Path $WorkDir) { Remove-Item -Recurse -Force $WorkDir -ErrorAction SilentlyContinue }
}