launch_mec_agent.ps1

2026-06-29




param(
  [string]$RuntimeRoot   = $null,
  [string]$WorkspaceDir  = $null,
  [string]$LlamaDir      = $null,
  [string]$ModelFile     = "",
  [string]$ModelPath     = $null,
  [int]$LlamaPort        = 8081,
  [string]$DotNanobotDir = $null
)

# ── Set Application User Model ID (must be before any window is created) ────────
# Aligns this process with the Start-Menu / desktop shortcut's AppUserModelID so
# Windows always shows mec_agent.ico in the taskbar and groups correctly.
# The same ID must be set in the [Icons] section of NanobotSetup.iss.
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class ShellAppId {
    [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
    public static extern int SetCurrentProcessExplicitAppUserModelID(string AppID);
}
'@ -ErrorAction SilentlyContinue
try { [ShellAppId]::SetCurrentProcessExplicitAppUserModelID("Pegatron.MECAgent") | Out-Null } catch {}

$InstallRoot = $PSScriptRoot
# Dev-mode path correction: when running from source the script lives in
# agent_packaging/launcher/ but the install root is agent_packaging/.
# In a real install the script is at {app}/ root, so this condition is never true.
if ((Split-Path -Leaf $InstallRoot) -eq 'launcher') {
  $InstallRoot = Split-Path -Parent $InstallRoot
}
# BaseDir is the parent of the install root.
# Production layout:  {base}\MEC_Agent  →  BaseDir = {base}
# Sibling dirs:       {base}\MEC_AgentData  and  {base}\Mec-wiki
$BaseDir = Split-Path -Parent $InstallRoot
if (-not $RuntimeRoot) {
  if ($env:NANOBOT_DATA_DIR) {
    $RuntimeRoot = $env:NANOBOT_DATA_DIR
  } else {
    $RuntimeRoot = Join-Path $BaseDir "MEC_AgentData"
  }
}
if (-not $WorkspaceDir) { $WorkspaceDir = Join-Path (Join-Path $RuntimeRoot ".mec_agent") "workspace" }
if (-not $LlamaDir) { $LlamaDir = Join-Path $InstallRoot "llama-cpp" }
if (-not $DotNanobotDir) { $DotNanobotDir = Join-Path $RuntimeRoot ".mec_agent" }
foreach ($d in @($RuntimeRoot, $DotNanobotDir, $WorkspaceDir, $LlamaDir)) {
  if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null }
}

$ModelPathFile = Join-Path $InstallRoot "model.path"

function Write-Info([string]$m){ Write-Host "[INFO] $m" }
function Write-Warn([string]$m){ Write-Warning $m }

# ── Read app version from version.json ─────────────────────────────────────────
$_versionJsonPath = Join-Path $InstallRoot "update\version.json"
$AppVersion = ""
if (Test-Path $_versionJsonPath) {
  try {
    $_vj = [System.IO.File]::ReadAllText($_versionJsonPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
    $AppVersion = $_vj.version
  } catch {
    Write-Warn "Failed to read version.json: $($_.Exception.Message)"
  }
}
if (-not $AppVersion) { $AppVersion = "dev" }
Write-Info "MEC Agent v$AppVersion"

function Release-SingleInstanceMutex() {
  if ($script:singleInstanceMutex) {
    try { $script:singleInstanceMutex.ReleaseMutex() | Out-Null } catch {}
    try { $script:singleInstanceMutex.Dispose() } catch {}
    $script:singleInstanceMutex = $null
  }
}

# Import environment variables from a Windows batch file (e.g. proxy\pega_proxy.cmd)
function Import-CmdEnv([string]$cmdPath) {
  if (-not (Test-Path $cmdPath)) { return }
  $cmdDir = (Split-Path -Parent $cmdPath).TrimEnd('\') + '\'
  $lines = Get-Content $cmdPath -ErrorAction SilentlyContinue
  foreach ($line in $lines) {
    $t = $line.Trim()
    if ($t -eq '') { continue }
    if ($t -match '^(?i:rem|::)') { continue }
    if ($t -match '^set\s+"([^=]+)=(.*)"$') {
      $name = $matches[1].Trim()
      if ($name -eq 'PROXY_DIR') { continue }
      $value = $matches[2].Trim()
      $value = $value.Replace('%~dp0', $cmdDir).Replace('%PROXY_DIR%', $cmdDir)
      Write-Info ("Importing env: {0} -> {1}" -f $name, $value)
      Set-Item -Path Env:$name -Value $value
      continue
    }
    if ($t -match '^\s*set\s+([^=\s]+)=(.*)$') {
      $name = $matches[1].Trim()
      if ($name -eq 'PROXY_DIR') { continue }
      $value = $matches[2].Trim()
      if ($value.StartsWith('"') -and $value.EndsWith('"')) { $value = $value.Substring(1,$value.Length-2) }
      $value = $value -replace '\\\\','\\'
      $value = $value.Replace('%~dp0', $cmdDir).Replace('%PROXY_DIR%', $cmdDir)
      Write-Info ("Importing env: {0} -> {1}" -f $name, $value)
      Set-Item -Path Env:$name -Value $value
    }
  }
}

function Set-ProxyCaVariables([string]$caPath) {
  if (-not (Test-Path $caPath)) {
    Write-Warn "CA bundle not found at $caPath"
    return
  }
  $env:REQUESTS_CA_BUNDLE = $caPath
  $env:SSL_CERT_FILE = $caPath
  $env:CURL_CA_BUNDLE = $caPath
  $env:NODE_EXTRA_CA_CERTS = $caPath
  Write-Info "Applied CA bundle to REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, and NODE_EXTRA_CA_CERTS"
}

function Copy-TextFileUtf8NoBom([string]$sourcePath, [string]$destinationPath) {
  $content = [System.IO.File]::ReadAllText($sourcePath)
  $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  [System.IO.File]::WriteAllText($destinationPath, $content, $utf8NoBom)
}

function Write-TextFileUtf8NoBom([string]$destinationPath, [string]$content) {
  $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  [System.IO.File]::WriteAllText($destinationPath, $content, $utf8NoBom)
}

function Copy-DirectoryIfMissing([string]$sourceDir, [string]$destinationDir) {
  if (-not (Test-Path $sourceDir)) { return }
  if (-not (Test-Path $destinationDir)) {
    New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
  }

  $sourceItems = Get-ChildItem -Path $sourceDir -Force -ErrorAction SilentlyContinue
  foreach ($item in $sourceItems) {
    $targetPath = Join-Path $destinationDir $item.Name
    if ($item.PSIsContainer) {
      Copy-DirectoryIfMissing $item.FullName $targetPath
    } elseif (-not (Test-Path $targetPath)) {
      Copy-Item -Path $item.FullName -Destination $targetPath -Force
    }
  }
}

function Write-NanobotWrapper([string]$wrapperPath, [string]$nanobotExe, [string]$configPath, [string]$workspaceDir) {
  $wrapperContent = @"
@echo off
setlocal

set "NANOBOT_EXE=$nanobotExe"
set "NANOBOT_CONFIG=$configPath"
set "NANOBOT_WORKSPACE=$workspaceDir"

if "%~1"=="" (
  "%NANOBOT_EXE%" gateway -c "%NANOBOT_CONFIG%" -w "%NANOBOT_WORKSPACE%"
) else if /i "%~1"=="gateway" (
  "%NANOBOT_EXE%" %* -c "%NANOBOT_CONFIG%" -w "%NANOBOT_WORKSPACE%"
) else if /i "%~1"=="agent" (
  "%NANOBOT_EXE%" %* -c "%NANOBOT_CONFIG%" -w "%NANOBOT_WORKSPACE%"
) else (
  "%NANOBOT_EXE%" %*
)
exit /b %ERRORLEVEL%
"@
  $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  [System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, $utf8NoBom)
}

function Test-TcpPortOpen([int]$port) {
  try {
    return [bool](Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
  } catch {
    return $false
  }
}

function Escape-CmdText([string]$text) {
  return $text -replace '"', '""'
}

function Quote-CmdArg([string]$text) {
  return '"' + (Escape-CmdText $text) + '"'
}

function Start-CmdWindow([string]$title, [string]$command, [string]$workingDirectory) {
  $cmdLine = "title $title && cd /d $(Quote-CmdArg $workingDirectory) && $command"
  return Start-Process -FilePath "cmd.exe" -ArgumentList "/k", $cmdLine -WorkingDirectory $workingDirectory -PassThru
}

function Start-LlamaBackground([string]$llamaExe, [string]$modelPath, [int]$port, [string]$workingDirectory, [string]$logPrefix) {
  if (Test-TcpPortOpen $port) {
    Write-Info "llama-server is already listening on port $port. Leaving the existing background process running."
    return $null
  }

  $llamaExePath = $llamaExe
  if (-not $llamaExePath -or -not (Test-Path $llamaExePath)) {
    $llamaExePath = Join-Path $workingDirectory "llama-server.exe"
  }
  if (-not (Test-Path $llamaExePath)) {
    Write-Error "llama-server.exe not found at $llamaExePath."
    exit 1
  }

  $stdoutLog = "$logPrefix.out.log"
  $stderrLog = "$logPrefix.err.log"
  $outputDir = Split-Path -Parent $stdoutLog
  if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }

  Write-Info "Starting llama.cpp from $llamaExePath in the background. Logs: $stdoutLog, $stderrLog"
  return Start-Process -FilePath $llamaExePath `
    -ArgumentList @('-m', $modelPath, '--port', $port, '-ngl', '99') `
    -WorkingDirectory $workingDirectory `
    -WindowStyle Hidden `
    -RedirectStandardOutput $stdoutLog `
    -RedirectStandardError $stderrLog `
    -PassThru
}

function Get-CompanyApiBase([string]$configPath) {
  if (-not (Test-Path $configPath)) {
    return $null
  }

  $content = Get-Content $configPath -Raw -ErrorAction SilentlyContinue
  if (-not $content) {
    return $null
  }

  $pattern = '"custom"\s*:\s*\{.*?"apiBase"\s*:\s*"([^"]+)"'
  $match = [regex]::Match($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
  if ($match.Success) {
    return $match.Groups[1].Value
  }

  return $null
}

function Test-ModelEndpointReachable([string]$apiBase) {
  if (-not $apiBase) {
    return $false
  }

  # Build proxy args — Invoke-WebRequest cannot parse credentials from the URL.
  # Parse user:pass@ manually and pass via -ProxyCredential.
  function _ProxyArgs {
    if (-not $env:HTTP_PROXY) { return @{} }
    $url = $env:HTTP_PROXY.Trim()
    $args = @{}
    if ($url -match '^https?://([^:@/]+):([^@/]+)@(.+)$') {
      $user    = [uri]::UnescapeDataString($Matches[1])
      $pass    = [uri]::UnescapeDataString($Matches[2])
      $hostPart = $Matches[3]
      $scheme   = if ($url -match '^https') { 'https' } else { 'http' }
      $args['Proxy']             = "${scheme}://$hostPart"
      $securePwd                 = ConvertTo-SecureString $pass -AsPlainText -Force
      $args['ProxyCredential']   = New-Object PSCredential($user, $securePwd)
      $args['ProxyUseDefaultCredentials'] = $false
    } else {
      $args['Proxy'] = $url
    }
    return $args
  }

  try {
    $iwArgs = @{
      Uri             = $apiBase
      Method          = 'Get'
      TimeoutSec      = 10
      UseBasicParsing = $true
      ErrorAction     = 'Stop'
    }
    $proxyArgs = _ProxyArgs
    foreach ($k in $proxyArgs.Keys) { $iwArgs[$k] = $proxyArgs[$k] }
    $response = Invoke-WebRequest @iwArgs
    return $true
  } catch {
    return [bool]$_.Exception.Response
  }
}

function Resolve-ModelPath([string]$explicitPath, [string]$runtimeRoot, [string]$installRoot, [string]$dotNanobotDir, [string]$fileName) {
  $candidates = @()
  if ($explicitPath) { $candidates += $explicitPath }
  if ($env:NANOBOT_MODEL_PATH) { $candidates += $env:NANOBOT_MODEL_PATH }
  $storedModelPathFile = Join-Path $installRoot "model.path"
  if (Test-Path $storedModelPathFile) { $candidates += (Get-Content $storedModelPathFile -Raw).Trim() }
  if ($fileName) {
    $candidates += (Join-Path $runtimeRoot $fileName)
    $candidates += (Join-Path $runtimeRoot "llama-cpp\$fileName")
    $candidates += (Join-Path $installRoot $fileName)
    $candidates += (Get-ChildItem -Path @($runtimeRoot, $installRoot) -Filter $fileName -Recurse -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1)
  }
  # Fallback: pick the first .gguf found in models\ under installRoot or runtimeRoot
  foreach ($searchRoot in @($installRoot, $runtimeRoot)) {
    $modelsDir = Join-Path $searchRoot "models"
    if (Test-Path $modelsDir) {
      $found = Get-ChildItem -Path $modelsDir -Filter "*.gguf" -File -ErrorAction SilentlyContinue | Select-Object -First 1
      if ($found) { $candidates += $found.FullName }
    }
  }

  foreach ($candidate in $candidates) {
    if ($candidate -and (Test-Path $candidate)) {
      return (Get-Item $candidate).FullName
    }
  }

  return $null
}

function Start-NanobotWindow([string]$wrapperPath, [string]$workingDirectory) {
  return Start-CmdWindow "MEC Agent" "call $(Quote-CmdArg $wrapperPath)" $workingDirectory
}

function Get-WebUiUrlFromConfig([string]$configPath) {
  $defaultUrl = "http://127.0.0.1:8765/"
  if (-not (Test-Path $configPath)) {
    return $defaultUrl
  }

  try {
    $cfg = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
    $ws = $cfg.channels.websocket
    if (-not $ws) {
      return $defaultUrl
    }

    $webHost = if ($ws.host) { [string]$ws.host } else { "127.0.0.1" }
    if ($webHost -eq "0.0.0.0") {
      $webHost = "127.0.0.1"
    }
    $port = if ($ws.port) { [int]$ws.port } else { 8765 }
    return "http://{0}:{1}/" -f $webHost, $port
  } catch {
    return $defaultUrl
  }
}

function Wait-HttpEndpoint([string]$url, [int]$timeoutSeconds) {
  $deadline = (Get-Date).AddSeconds($timeoutSeconds)
  while ((Get-Date) -lt $deadline) {
    try {
      $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop
      if ($resp.StatusCode -lt 500) {
        return $true
      }
    } catch {
      # keep waiting
    }
    Start-Sleep -Milliseconds 250
  }
  return $false
}

function Get-WikiRootFromSkill([string]$skillFilePath) {
  $defaultPath = "D:\llm-wiki"
  if (-not (Test-Path $skillFilePath)) {
    return $defaultPath
  }

  $content = Get-Content $skillFilePath -Raw -ErrorAction SilentlyContinue
  if (-not $content) {
    return $defaultPath
  }

  $match = [regex]::Match($content, 'Windows\*\*:\s*`([^`]+)`')
  if ($match.Success) {
    return $match.Groups[1].Value.Trim().TrimEnd('\\')
  }

  return $defaultPath
}

function Find-Python {
  # 0. Bundled embed Python (highest priority — guaranteed isolated from user system)
  $embedPy = Join-Path $InstallRoot "python\python.exe"
  if (Test-Path $embedPy) { return (Resolve-Path $embedPy).Path }

  # 1. Check alongside this script (e.g. dev .venv)
  $venvPy = Join-Path $InstallRoot "..\..venv\Scripts\python.exe"
  if (Test-Path $venvPy) { return (Resolve-Path $venvPy).Path }

  # 2. Windows py launcher
  $py = Get-Command "py" -ErrorAction SilentlyContinue
  if ($py) { return "py" }

  # 3. python3 / python in PATH
  foreach ($name in @("python3", "python")) {
    $cmd = Get-Command $name -ErrorAction SilentlyContinue
    if ($cmd -and $cmd.Source) { return $cmd.Source }
  }

  # 4. Common install locations (Python 3.8+)
  $roots = @($env:LOCALAPPDATA, $env:ProgramFiles, "C:\")
  foreach ($root in $roots) {
    if (-not $root) { continue }
    foreach ($ver in @("Python313", "Python312", "Python311", "Python310", "Python39", "Python38")) {
      $candidate = Join-Path $root "Programs\Python\$ver\python.exe"
      if (Test-Path $candidate) { return $candidate }
      $candidate = Join-Path $root "$ver\python.exe"
      if (Test-Path $candidate) { return $candidate }
    }
  }

  return $null
}

# Ensure an isolated Python environment for skill scripts.
#
# Two modes depending on what is available at {app}\python:
#
#  A. Bundled embed Python exists → use it directly as the isolated Python.
#     Packages are installed into its own Lib\site-packages.
#     Its ._pth file already prevents it from loading system site-packages,
#     so it is fully isolated without needing a separate venv.
#     Returns {app}\python\python.exe.
#
#  A-recovery. python.exe is missing BUT downloads\python-*-embed-amd64.zip
#     exists → extract it, enable site-packages, bootstrap pip, then proceed
#     as Mode A.  This self-heals after a python_embed update that failed to
#     run, or after the user manually deleted python\ contents.
#
#  B. No embed Python and no recovery ZIP → fall back to creating a .skill-venv
#     from system Python.  Packages are installed into the venv's site-packages.
#     Returns {app}\.skill-venv\Scripts\python.exe.
#
# In both cases the returned path is set as MEC_PYTHON_EXE and the Scripts/
# directory containing python.exe is prepended to PATH so that bare `python`
# and `pip` commands in ALL child processes (including nanobot tool calls)
# resolve to this isolated Python.
#
#  Mode A-recovery. python.exe is missing BUT downloads\python-*-embed-amd64.zip
#     exists -> extract it, enable site-packages, bootstrap pip, then proceed
#     as Mode A.  Self-heals after a failed python_embed update or manual deletion.
function Ensure-SkillPython {
  $EmbedPy     = Join-Path $InstallRoot "python\python.exe"
  $VenvDir     = Join-Path $InstallRoot ".skill-venv"
  $VenvPy      = Join-Path $VenvDir "Scripts\python.exe"
  $WhlDir      = Join-Path $InstallRoot "whl"
  $SkillWhlDir = Join-Path $InstallRoot "skill_whl"
  $skillPkgs   = @("python-pptx", "pymupdf", "pillow", "lxml", "lxml-html-clean", "xlsxwriter",
                    "requests", "openai", "google-genai")

  # ── Mode A-recovery: rebuild python\ from downloads\ embed ZIP ────────────
  # If python.exe is missing but a raw embed ZIP exists in downloads\, extract
  # it, enable site-packages, and bootstrap pip so Mode A can take over.
  if (-not (Test-Path $EmbedPy)) {
    $DownloadsDir = Join-Path $InstallRoot "downloads"
    $embedZip = Get-ChildItem -Path $DownloadsDir -Filter "python-*-embed-amd64.zip" `
      -File -ErrorAction SilentlyContinue |
      Sort-Object Name -Descending | Select-Object -First 1
    if ($embedZip) {
      Write-Info "python\ missing — rebuilding from $($embedZip.Name)..."
      try {
        Add-Type -AssemblyName System.IO.Compression.FileSystem
        $PythonDir = Split-Path -Parent $EmbedPy
        if (-not (Test-Path $PythonDir)) {
          New-Item -ItemType Directory -Path $PythonDir -Force | Out-Null
        }
        # Extract to temp dir first, then copy (handles non-empty target safely)
        $tmpExt = Join-Path $env:TEMP "mec_pyembed_$(Get-Random)"
        [System.IO.Compression.ZipFile]::ExtractToDirectory($embedZip.FullName, $tmpExt)
        Get-ChildItem -Path $tmpExt | Copy-Item -Destination $PythonDir -Recurse -Force
        Remove-Item $tmpExt -Recurse -Force -ErrorAction SilentlyContinue
        # Enable site-packages: raw embed has '#import site' — uncomment it
        $pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" -File |
          Select-Object -First 1
        if ($pthFile) {
          $pth = [System.IO.File]::ReadAllText($pthFile.FullName)
          if ($pth -notmatch '(?m)^import site') {
            $pth = $pth -replace '(?m)^#\s*import site', 'import site'
            if ($pth -notmatch '(?m)^import site') {
              $pth = $pth.TrimEnd() + "`r`nimport site`r`n"
            }
            [System.IO.File]::WriteAllText($pthFile.FullName, $pth)
          }
        }
        # Ensure Lib\site-packages exists (pip install target)
        $siteDir = Join-Path $PythonDir "Lib\site-packages"
        if (-not (Test-Path $siteDir)) {
          New-Item -ItemType Directory -Path $siteDir -Force | Out-Null
        }
        # Bootstrap pip via ensurepip (bundled in Python 3.12 stdlib)
        Write-Info "Bootstrapping pip in recovered python\..."
        & $EmbedPy -m ensurepip --upgrade 2>&1 | ForEach-Object { Write-Info $_ }
        Write-Info "python\ rebuilt successfully from $($embedZip.Name)"
      } catch {
        Write-Warn "Failed to rebuild python\ from embed ZIP: $($_.Exception.Message)"
      }
    }
  }

  # ── Mode A: use bundled embed Python ──────────────────────────────────────
  if (Test-Path $EmbedPy) {
    Write-Info "Using bundled embed Python: $EmbedPy"
    $PythonDir = Split-Path -Parent $EmbedPy
    # Ensure Lib\site-packages exists (may be absent after a raw python_embed update)
    $siteDir = Join-Path $PythonDir "Lib\site-packages"
    if (-not (Test-Path $siteDir)) {
      New-Item -ItemType Directory -Path $siteDir -Force | Out-Null
    }
    # Ensure pip is installed; bootstrap via ensurepip when missing.
    # python_embed update packages may ship the raw embed (no pre-installed pip).
    $pipCheck = (& $EmbedPy -m pip --version 2>&1) -join ''
    if ($LASTEXITCODE -ne 0 -or $pipCheck -notmatch 'pip') {
      Write-Info "pip not found in embed Python — bootstrapping via ensurepip..."
      & $EmbedPy -m ensurepip --upgrade 2>&1 | ForEach-Object { Write-Info $_ }
    }
    # Pre-install skill packages (idempotent — pip skips already-installed).
    # Search both skill_whl/ (skill-specific) and whl/ (nanobot core deps as fallback).
    $whlSources = @($SkillWhlDir, $WhlDir) | Where-Object { Test-Path $_ }
    if ($whlSources.Count -gt 0) {
      $flArgs = $whlSources | ForEach-Object { '--find-links', $_ }
      foreach ($pkg in $skillPkgs) {
        & $EmbedPy -m pip install $pkg @flArgs --no-index --quiet 2>&1 | Out-Null
      }
      Write-Info "Skill packages ensured in embed Python from skill_whl/ + whl/"
    }
    return $EmbedPy
  }

  # ── Mode B: fall back to system Python + venv ─────────────────────────────
  Write-Warn "Bundled embed Python not found at $EmbedPy — falling back to system Python."
  Write-Warn "NOTE: whl/ packages are built for Python 3.12; some may not install on other versions."

  if (-not (Test-Path $VenvPy)) {
    $BasePy = Find-Python
    if (-not $BasePy) {
      Write-Warn "No Python found — skill features requiring Python will be unavailable."
      return $null
    }
    # Check Python version — warn if not 3.12
    $pyVer = (& $BasePy -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim()
    if ($pyVer -ne '3.12') {
      Write-Warn "System Python is $pyVer; bundled wheels require Python 3.12. Some packages (lxml, pillow) may not install. Consider restoring the bundled python/ directory."
    }
    Write-Info "Creating isolated skill venv (first launch only) from: $BasePy"
    & $BasePy -m venv $VenvDir 2>&1 | ForEach-Object { Write-Info $_ }
    if (-not (Test-Path $VenvPy)) {
      Write-Warn "Skill venv creation failed — skill Python features may be unavailable."
      return $null
    }
    # Pre-install packages from offline cache; log failures (whl may be version-specific).
    # Search both skill_whl/ (skill-specific) and whl/ (nanobot core deps as fallback).
    $whlSources = @($SkillWhlDir, $WhlDir) | Where-Object { Test-Path $_ }
    if ($whlSources.Count -gt 0) {
      $flArgs = $whlSources | ForEach-Object { '--find-links', $_ }
      $failedPkgs = @()
      foreach ($pkg in $skillPkgs) {
        $out = & $VenvPy -m pip install $pkg @flArgs --no-index --quiet 2>&1
        if ($LASTEXITCODE -ne 0) { $failedPkgs += $pkg }
      }
      if ($failedPkgs.Count -gt 0) {
        Write-Warn "Failed to install from skill_whl/ + whl/: $($failedPkgs -join ', ') — these require Python 3.12 wheels"
      }
      Write-Info "Skill packages installed into venv from skill_whl/ + whl/"
    }
    Write-Info "Skill venv ready: $VenvDir"
  } else {
    Write-Info "Skill venv already exists: $VenvDir"
  }

  return $VenvPy
}

function Start-ApiServerExe([string]$exePath, [string]$configPath, [string]$workspaceDir, [string]$logPrefix) {
  $stdoutLog = "$logPrefix.out.log"
  $stderrLog = "$logPrefix.err.log"
  $outputDir = Split-Path -Parent $stdoutLog
  if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }

  $apiArgs = @('--config', $configPath, '--workspace', $workspaceDir, '--port', '47391', '--parent-pid', $PID)

  Write-Info "Starting api_server (exe) on http://127.0.0.1:47391 (logs: $stdoutLog)"
  return Start-Process -FilePath $exePath `
    -ArgumentList $apiArgs `
    -WindowStyle Hidden `
    -RedirectStandardOutput $stdoutLog `
    -RedirectStandardError $stderrLog `
    -PassThru
}

function Start-ApiServerBackground([string]$pythonExe, [string]$serverScript, [string]$configPath, [string]$workspaceDir, [string]$logPrefix) {
  if (-not (Test-Path $serverScript)) {
    Write-Warn "api_server script not found at $serverScript — skipping"
    return $null
  }

  $stdoutLog = "$logPrefix.out.log"
  $stderrLog = "$logPrefix.err.log"
  $outputDir = Split-Path -Parent $stdoutLog
  if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }

  $apiArgs = @($serverScript, '--config', $configPath, '--workspace', $workspaceDir, '--port', '47391', '--parent-pid', $PID)

  Write-Info "Starting api_server (script) on http://127.0.0.1:47391 (logs: $stdoutLog)"
  return Start-Process -FilePath $pythonExe `
    -ArgumentList $apiArgs `
    -WindowStyle Hidden `
    -RedirectStandardOutput $stdoutLog `
    -RedirectStandardError $stderrLog `
    -PassThru
}

# Query nvidia-smi (bundled with every NVIDIA display driver) for the maximum
# CUDA version the currently-installed driver supports.
# Returns a string such as '13.1' or '12.4', or $null when unavailable.
function Get-NvidiaCudaVersion {
  try {
    $lines = & nvidia-smi 2>$null
    foreach ($line in $lines) {
      if ($line -match 'CUDA\s+Version:\s*(\d+\.\d+)') { return $Matches[1] }
    }
  } catch {}
  return $null
}

# Detect the GPU(s) present in the system and return the path of the best-matching
# bundled llama.cpp release zip.
#
# Selection priority (highest first):
#   NVIDIA             -> CUDA 13.x  >  CUDA 12.x  >  Vulkan
#   AMD Radeon         -> Vulkan  (HIP/ROCm requires a manual install on Windows)
#   Intel Arc / Iris Xe / UHD 7xx-9xx (11th-gen+) -> SYCL
#   Intel Iris Plus / HD / UHD 6xx (older iGPU)   -> Vulkan  (SYCL unsupported)
#   No GPU / unrecognised                          -> CPU (x64 or arm64)
#
# Zip files are discovered via glob (llama-b*-bin-win-VARIANT.zip) so the build
# number (e.g. b9049) does not need to be hard-coded; the highest number wins.
function Get-LlamaZipPath([string]$installRoot) {
  # Return the path of the highest-build-number zip for the given suffix glob.
  # Example: _FindZip 'vulkan-x64'   -> llama-bNNNN-bin-win-vulkan-x64.zip
  #          _FindZip 'cuda-12*-x64' -> llama-bNNNN-bin-win-cuda-12.4-x64.zip
  function _FindZip([string]$suffix) {
    $hit = Get-ChildItem -Path $installRoot -Filter "llama-b*-bin-win-$suffix.zip" `
             -File -ErrorAction SilentlyContinue |
           Sort-Object Name -Descending |
           Select-Object -First 1
    if ($hit) { return $hit.FullName }
    return $null
  }

  $bestPri = 0
  $bestZip = $null

  try {
    $gpus = Get-CimInstance -ClassName Win32_VideoController -ErrorAction SilentlyContinue
    foreach ($gpu in $gpus) {
      $n = [string]$gpu.Name

      # ── NVIDIA: prefer CUDA 13.x > CUDA 12.x > Vulkan (priority 4) ────────
      if ($n -match 'NVIDIA|GeForce|RTX|GTX|Quadro') {
        $cudaVer = Get-NvidiaCudaVersion
        $cudaMajor = if ($cudaVer -match '^(\d+)\.') { [int]$Matches[1] } else { 0 }
        $zip = $null; $tag = ''
        if ($cudaMajor -ge 13) {
          $zip = _FindZip 'cuda-13*-x64'; $tag = "CUDA 13.x (driver CUDA $cudaVer)"
        }
        if (-not $zip) {
          $zip = _FindZip 'cuda-12*-x64'
          $tag = if ($cudaVer) { "CUDA 12.x (driver CUDA $cudaVer)" } else { 'CUDA 12.x' }
        }
        if (-not $zip) {
          $zip = _FindZip 'vulkan-x64'; $tag = 'Vulkan (no CUDA zip found)'
        }
        if ($zip -and 4 -gt $bestPri) {
          Write-Host "[INFO] Detected NVIDIA GPU ($n) -> $tag"
          $bestPri = 4; $bestZip = $zip
        }
        continue
      }

      # ── AMD Radeon: Vulkan (HIP requires ROCm manual install) (priority 3) ─
      if ($n -match 'AMD|Radeon') {
        $zip = _FindZip 'vulkan-x64'
        if ($zip -and 3 -gt $bestPri) {
          Write-Host "[INFO] Detected AMD GPU ($n) -> Vulkan (HIP/ROCm requires manual install)"
          $bestPri = 3; $bestZip = $zip
        }
        continue
      }

      # ── Intel SYCL-capable: Arc, Iris Xe, UHD 7xx/8xx/9xx (11th-gen+) ─────
      # SYCL requires Intel oneAPI Level-Zero runtime; Arc and Iris Xe include it
      # in the release zip. Older iGPUs (Iris Plus, UHD 6xx, HD) are NOT supported.
      # When SYCL zip is not bundled, fall back to Vulkan (also GPU-accelerated).
      if ($n -match 'Intel.*(Arc|Iris.*Xe|UHD\D+[7-9]\d{2})') {
        $zip = _FindZip 'sycl-x64'
        if ($zip -and 2 -gt $bestPri) {
          Write-Host "[INFO] Detected Intel SYCL-capable GPU ($n) -> SYCL"
          $bestPri = 2; $bestZip = $zip
        } else {
          # SYCL zip not bundled; Vulkan is also GPU-accelerated on Arc/Iris Xe
          $zip = _FindZip 'vulkan-x64'
          if ($zip -and 2 -gt $bestPri) {
            Write-Host "[INFO] Detected Intel SYCL-capable GPU ($n) -> Vulkan (SYCL zip not available)"
            $bestPri = 2; $bestZip = $zip
          }
        }
        continue
      }

      # ── Intel older iGPU (Iris Plus, HD Graphics, UHD 6xx, …): Vulkan ──────
      # SYCL does not support these models; Vulkan works for basic inference.
      if ($n -match 'Intel') {
        $zip = _FindZip 'vulkan-x64'
        if ($zip -and 1 -gt $bestPri) {
          Write-Host "[INFO] Detected Intel GPU ($n) -> Vulkan (SYCL not supported on this model)"
          $bestPri = 1; $bestZip = $zip
        }
        continue
      }
    }
  } catch {
    Write-Host "[WARN] GPU detection failed ($($_.Exception.Message)) - falling back to CPU."
  }

  if ($bestZip) { return $bestZip }

  # CPU fallback - detect x64 vs arm64 via the OS environment variable
  $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
  Write-Host "[INFO] No GPU-accelerated variant selected - using CPU ($arch)."
  $cpuZip = _FindZip "cpu-$arch"
  if ($cpuZip) { return $cpuZip }

  # Absolute last resort: try any known variant
  foreach ($fb in @('cpu-x64', 'vulkan-x64', 'cpu-arm64', 'sycl-x64')) {
    $p = _FindZip $fb
    if ($p) { Write-Host "[WARN] Using last-resort fallback '$fb'."; return $p }
  }
  return $null
}

function Ensure-LlamaRuntime([string]$installRoot) {
  $llamaZip = Get-LlamaZipPath (Join-Path $installRoot 'llama')
  if (-not $llamaZip) {
    Write-Error "No bundled llama archive found in $(Join-Path $installRoot 'llama')."
    exit 1
  }

  $desiredVariant = [System.IO.Path]::GetFileNameWithoutExtension($llamaZip)
  $variantMarker  = Join-Path $LlamaDir "llama-variant.txt"

  # Re-extract when: llama-server.exe missing, or extracted variant differs from desired.
  $needExtract = $true
  $llamaExe = Get-ChildItem -Path $LlamaDir -Filter "llama-server.exe" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
  if ($llamaExe -and (Test-Path $variantMarker)) {
    $currentVariant = (Get-Content $variantMarker -Raw -ErrorAction SilentlyContinue).Trim()
    if ($currentVariant -eq $desiredVariant) {
      $needExtract = $false
    } else {
      Write-Info "GPU variant changed ($currentVariant -> $desiredVariant). Re-extracting llama runtime..."
      Remove-Item -Path $LlamaDir -Recurse -Force -ErrorAction SilentlyContinue
    }
  }

  if ($needExtract) {
    Write-Info "Extracting llama runtime from $(Split-Path -Leaf $llamaZip) ..."
    if (-not (Test-Path $LlamaDir)) {
      New-Item -ItemType Directory -Path $LlamaDir -Force | Out-Null
    }
    Expand-Archive -Path $llamaZip -DestinationPath $LlamaDir -Force
    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
    [System.IO.File]::WriteAllText($variantMarker, $desiredVariant, $utf8NoBom)
    Write-Info "Extracted llama runtime ($desiredVariant) to $LlamaDir"
  }

  $llamaExe = Get-ChildItem -Path $LlamaDir -Filter "llama-server.exe" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
  if (-not $llamaExe) {
    Write-Error "llama-server.exe not found in $LlamaDir after extraction."
    exit 1
  }
  return $llamaExe.FullName
}

# ── Single-instance guard ─────────────────────────────────────────────────────
# The port check below only works after the gateway has finished binding. A named
# mutex closes the earlier startup window, when the user can still double-launch.
$script:singleInstanceMutex = $null
$_singleInstanceCreated = $false
try {
  $script:singleInstanceMutex = New-Object System.Threading.Mutex($true, "Local\Pegatron.MECAgent.4F2B6A7E-0C99-4F2F-AE6E-8E57F24DD6A1", [ref]$_singleInstanceCreated)
} catch {
  Write-Warn "Could not create MEC Agent single-instance mutex; falling back to port check. $_"
}

if ($script:singleInstanceMutex -and -not $_singleInstanceCreated) {
  Write-Info "MEC Agent is already starting or running. Skipping new instance."
  Release-SingleInstanceMutex
  exit 0
}

# If the MEC Agent gateway is already listening on port 18790, MEC Agent is already
# running. Exit immediately — do NOT open another control panel or browser tab.
$_alreadyRunning = $false
$_singleCheckTcp = $null
try {
  $_singleCheckTcp = New-Object System.Net.Sockets.TcpClient
  $_singleCheckAr  = $_singleCheckTcp.BeginConnect('127.0.0.1', 18790, $null, $null)
  $_alreadyRunning = $_singleCheckAr.AsyncWaitHandle.WaitOne(500)
} catch {}
if ($_singleCheckTcp) { try { $_singleCheckTcp.Close() } catch {} }

if ($_alreadyRunning) {
  Write-Info "MEC Agent is already running (port 18790). Skipping new instance."
  Release-SingleInstanceMutex
  exit 0
}

# ── Startup splash ────────────────────────────────────────────────────────────
# Show a lightweight splash window immediately (on a separate STA runspace) so
# the user sees feedback while services initialise (can take > 5 s).
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$script:splashCloseEvent = New-Object System.Threading.ManualResetEvent($false)
$_splashIconFile  = Join-Path $InstallRoot "mec_agent.ico"
$_splashStatusFile = Join-Path $InstallRoot "update\_splash_status.txt"
# Clean any stale status left by a previous run
Remove-Item $_splashStatusFile -Force -ErrorAction SilentlyContinue
$_splashScript = {
  param($closeEvt, $iconFile, $statusFile, $appVer)
  Add-Type -AssemblyName System.Windows.Forms
  Add-Type -AssemblyName System.Drawing
  $s = New-Object System.Windows.Forms.Form
  $s.Text            = "MEC Agent v$appVer"
  $s.ClientSize      = New-Object System.Drawing.Size(400, 140)
  $s.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedToolWindow
  $s.StartPosition   = [System.Windows.Forms.FormStartPosition]::CenterScreen
  $s.BackColor       = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $s.TopMost         = $true
  $s.ControlBox      = $false
  if ($iconFile -and (Test-Path $iconFile)) {
    try { $s.Icon = New-Object System.Drawing.Icon($iconFile) } catch {}
  }
  $t = New-Object System.Windows.Forms.Label
  $t.Text      = "MEC Agent v$appVer"
  $t.ForeColor = [System.Drawing.Color]::White
  $t.Font      = New-Object System.Drawing.Font("Segoe UI", 16, [System.Drawing.FontStyle]::Bold)
  $t.AutoSize  = $true
  $t.Location  = New-Object System.Drawing.Point(20, 18)
  $a = New-Object System.Windows.Forms.Panel
  $a.Location  = New-Object System.Drawing.Point(0, 62)
  $a.Size      = New-Object System.Drawing.Size(400, 3)
  $a.BackColor = [System.Drawing.Color]::FromArgb(212, 93, 36)
  $u = New-Object System.Windows.Forms.Label
  $u.Text      = "⏳  正在啟動服務,請稍候..."
  $u.ForeColor = [System.Drawing.Color]::FromArgb(170, 190, 215)
  $u.Font      = New-Object System.Drawing.Font("Segoe UI", 9)
  $u.AutoSize  = $true
  $u.Location  = New-Object System.Drawing.Point(22, 80)
  $s.Controls.AddRange(@($t, $a, $u))
  $tmr = New-Object System.Windows.Forms.Timer
  $tmr.Interval = 300
  $tmr.add_Tick({
    if ($closeEvt.WaitOne(0)) { $tmr.Stop(); $s.Close(); return }
    if ($statusFile -and (Test-Path $statusFile)) {
      try {
        $msg = [System.IO.File]::ReadAllText($statusFile).Trim()
        if ($msg -and $msg -ne $u.Text) { $u.Text = $msg }
      } catch {}
    }
  })
  $tmr.Start()
  [System.Windows.Forms.Application]::Run($s)
}
$_splashRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$_splashRS.ApartmentState = [System.Threading.ApartmentState]::STA
$_splashRS.ThreadOptions  = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread
$_splashRS.Open()
$_splashPS = [System.Management.Automation.PowerShell]::Create()
$_splashPS.Runspace = $_splashRS
[void]$_splashPS.AddScript($_splashScript).AddArgument($script:splashCloseEvent).AddArgument($_splashIconFile).AddArgument($_splashStatusFile).AddArgument($AppVersion)
$null = $_splashPS.BeginInvoke()

# ── Complete deferred exe replacement (from a previous apply_staged) ──────────
# When apply_staged cannot replace the running api_server.exe (Windows locks it),
# it saves the new exe to update/_pending_new_api_server.exe and writes the
# _exe_pending_replace flag.  At launcher startup no api_server is running yet,
# so we can safely overwrite the exe here.
$ExePendingFlag  = Join-Path $InstallRoot "update\_exe_pending_replace"
$PendingNewExe   = Join-Path $InstallRoot "update\_pending_new_api_server.exe"
$ApiServerTarget = Join-Path $InstallRoot "api_server\api_server.exe"
if ((Test-Path $ExePendingFlag) -and (Test-Path $PendingNewExe)) {
  Write-Info "Deferred exe replacement detected — swapping api_server.exe..."
  try {
    # Safety: ensure no api_server process is running
    $running = Get-Process -Name "api_server" -ErrorAction SilentlyContinue
    if ($running) {
      Write-Warn "api_server.exe still running — stopping it before deferred replace..."
      Stop-Process -Name "api_server" -Force -ErrorAction SilentlyContinue
      Start-Sleep -Milliseconds 500
    }
    Copy-Item -Path $PendingNewExe -Destination $ApiServerTarget -Force
    Write-Info "Deferred exe replacement completed successfully."
    Remove-Item $ExePendingFlag -Force -ErrorAction SilentlyContinue
    Remove-Item $PendingNewExe  -Force -ErrorAction SilentlyContinue
  } catch {
    Write-Warn "Deferred exe replacement failed: $($_.Exception.Message)"
    Write-Warn "The new exe will be swapped on the next launcher restart."
  }
} elseif (Test-Path $ExePendingFlag) {
  Write-Warn "Deferred exe flag found but pending exe file missing — cleaning up flag."
  Remove-Item $ExePendingFlag -Force -ErrorAction SilentlyContinue
}

# ── Apply pending staged update (if any) before starting services ─────────────
# Use mec_updater.ps1 -ApplyStaged instead of api_server.exe --apply-staged.
#
# WHY: When api_server.exe --apply-staged runs, the executing exe (which may be
# the OLD version still on disk) is INSIDE the api_server/ directory.  Windows
# locks a running executable — Remove-Item / shutil.rmtree fails with WinError 32,
# leaving the api_server/ directory half-deleted and broken.
#
# mec_updater.ps1 runs in a SEPARATE PowerShell process that does NOT live inside
# api_server/.  The launcher has already stopped api_server.exe (taskkill in the
# finally block), so the exe is not locked and the entire directory can be safely
# replaced.  This eliminates the chicken-and-egg problem where the new exe (with
# the fix) is inside the update ZIP but the old exe (without the fix) is the one
# that runs --apply-staged.
#
# FALLBACK: If mec_updater.ps1 is missing (very old install), fall back to
# api_server.exe --apply-staged with the pre-extract workaround.
$PendingUpdateFlag = Join-Path $InstallRoot "update\_pending_update"
$ApiServerExe      = Join-Path $InstallRoot "api_server\api_server.exe"
$_updaterProgress  = Join-Path $InstallRoot "update\_progress.json"
$_stagedDir        = Join-Path $InstallRoot "update\_staged_update"
$_mecUpdaterPs1    = Join-Path $InstallRoot "update\mec_updater.ps1"

if ((Test-Path $PendingUpdateFlag) -and (Test-Path $ApiServerExe)) {
  Write-Info "Pending update detected — applying staged update before starting services..."
  try {
    [System.IO.File]::WriteAllText($_splashStatusFile, "⏳  正在套用更新,請稍候...", (New-Object System.Text.UTF8Encoding($false)))

    # ── Ensure api_server.exe is NOT running (safety net) ────────────────
    $runningAS = Get-Process -Name "api_server" -ErrorAction SilentlyContinue
    if ($runningAS) {
      Write-Warn "api_server.exe still running before apply — stopping it..."
      Stop-Process -Name "api_server" -Force -ErrorAction SilentlyContinue
      Start-Sleep -Milliseconds 1000
    }

    # ── Choose updater: mec_updater.ps1 (preferred) or api_server.exe ───
    $usedPs1Updater = $false
    if (Test-Path $_mecUpdaterPs1) {
      Write-Info "Using mec_updater.ps1 -ApplyStaged (standalone updater)"
      $updateProc = Start-Process powershell.exe `
        -ArgumentList @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
                        "-File", $_mecUpdaterPs1, "-ApplyStaged") `
        -WindowStyle Hidden -PassThru
      $usedPs1Updater = $true
    } else {
      Write-Warn "mec_updater.ps1 not found — falling back to api_server.exe --apply-staged"

      # ── Pre-extract new api_server.exe from staged ZIP ──────────────────
      # When falling back to the exe, we must ensure the NEW exe (with the
      # _replace_directory_files fix) is the one that runs --apply-staged.
      $applyExe = $ApiServerExe   # default: use the on-disk exe
      $_stagedApiServerZip = Join-Path $_stagedDir "api_server.zip"
      if (Test-Path $_stagedApiServerZip) {
        try {
          Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
          $_zip = [System.IO.Compression.ZipFile]::OpenRead($_stagedApiServerZip)
          $_newExeEntry = $null
          foreach ($_entry in $_zip.Entries) {
            if ($_entry.FullName -match '(^|/)api_server\.exe$') {
              $_newExeEntry = $_entry
              break
            }
          }
          if ($_newExeEntry) {
            $_preExtractDir = Join-Path $_stagedDir "_pre_extract_api_server"
            if (-not (Test-Path $_preExtractDir)) { New-Item -ItemType Directory -Path $_preExtractDir -Force | Out-Null }
            $_preExtractExe = Join-Path $_preExtractDir "api_server.exe"
            [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_newExeEntry, $_preExtractExe, $true)
            $applyExe = $_preExtractExe
            Write-Info "Pre-extracted new api_server.exe from staged ZIP for apply-staged"
          }
          $_zip.Dispose()
        } catch {
          Write-Warn "Could not pre-extract api_server.exe from staged ZIP: $($_.Exception.Message)"
          Write-Warn "Falling back to on-disk exe for --apply-staged"
        }
      }

      # api_server.exe --apply-staged (fallback)
      $updateProc = Start-Process $applyExe `
        -ArgumentList @("--apply-staged", "--install-root", $InstallRoot) `
        -WindowStyle Hidden -PassThru
    }

    while (-not $updateProc.HasExited) {
      if (Test-Path $_updaterProgress) {
        try {
          $prog = [System.IO.File]::ReadAllText($_updaterProgress) | ConvertFrom-Json
          if ($prog.phase -eq 'applying' -and $prog.component) {
            [System.IO.File]::WriteAllText($_splashStatusFile,
              "⏳  套用更新 ($($prog.current)/$($prog.total)): $($prog.component)",
              (New-Object System.Text.UTF8Encoding($false)))
          }
        } catch {}
      }
      Start-Sleep -Milliseconds 400
    }
    $updateProc.WaitForExit()
    Write-Info "Update apply finished (exit code: $($updateProc.ExitCode))"

    # ── Clean up pre-extracted temp directory ───────────────────────────
    $_preExtractDir = Join-Path $_stagedDir "_pre_extract_api_server"
    if (Test-Path $_preExtractDir) {
      try { Remove-Item $_preExtractDir -Recurse -Force -ErrorAction SilentlyContinue } catch {}
    }

    [System.IO.File]::WriteAllText($_splashStatusFile, "✅  更新完成,正在啟動服務...", (New-Object System.Text.UTF8Encoding($false)))
    Start-Sleep -Milliseconds 1200
  } catch {
    Write-Warn "Failed to apply staged update: $($_.Exception.Message)"

    # Clean up pre-extracted temp directory on failure too
    $_preExtractDir = Join-Path $_stagedDir "_pre_extract_api_server"
    if (Test-Path $_preExtractDir) {
      try { Remove-Item $_preExtractDir -Recurse -Force -ErrorAction SilentlyContinue } catch {}
    }
  }
}

# If a proxy setup script exists, import its environment variables into PowerShell
$proxyCmd = Join-Path $InstallRoot "proxy\pega_proxy.cmd"
$proxyCaBundle = Join-Path $InstallRoot "proxy\PEGA-CA-02.pem"
if (Test-Path $proxyCmd) {
  Write-Info "Loading proxy environment from $proxyCmd"
  Import-CmdEnv $proxyCmd
  Set-ProxyCaVariables $proxyCaBundle
} else {
  Write-Warn "Proxy script not found at $proxyCmd — skipping"
}

# Always apply CA bundle from config\ if present (works without proxy script)
$configCaBundle = Join-Path $InstallRoot "config\pegatron-ca-bundle.pem"
if (Test-Path $configCaBundle) {
  Set-ProxyCaVariables $configCaBundle
  Write-Info "Applied enterprise CA bundle from config\pegatron-ca-bundle.pem"
}

# --- Set nanobot runtime environment variables ---
# NANOBOT_LLM_TIMEOUT_S: per-LLM-request wall-clock timeout in seconds.
# Default in nanobot is 300 (5 min); override here for slower endpoints.
# Set to 0 to disable the timeout entirely.
if (-not $env:NANOBOT_LLM_TIMEOUT_S) {
  $env:NANOBOT_LLM_TIMEOUT_S = "600"
  Write-Info "NANOBOT_LLM_TIMEOUT_S not set — defaulting to 600s (10 min)"
} else {
  Write-Info "NANOBOT_LLM_TIMEOUT_S = $env:NANOBOT_LLM_TIMEOUT_S (user override)"
}

# --- Locate bundled mec_agent.exe ---
$NanobotExe = Join-Path $InstallRoot "mec_agent.exe"
if (-not (Test-Path $NanobotExe)) {
  Write-Error "Bundled mec_agent.exe not found at $NanobotExe. Please re-run the installer."
  exit 1
}

$CompanyConfigSource = Join-Path $InstallRoot "config\config.json"
$LlamaConfigSource = Join-Path $InstallRoot "config\config_llama_cpp.json"
if (-not (Test-Path $CompanyConfigSource)) {
  Write-Error "config.json not found at $CompanyConfigSource"
  exit 1
}
if (-not (Test-Path $LlamaConfigSource)) {
  Write-Error "config_llama_cpp.json not found at $LlamaConfigSource"
  exit 1
}

$CompanyApiBase = Get-CompanyApiBase $CompanyConfigSource
$UseLlamaRuntime = $false
$ActiveConfigSource = $CompanyConfigSource
if ($CompanyApiBase -and -not (Test-ModelEndpointReachable $CompanyApiBase)) {
  $UseLlamaRuntime = $true
  $ActiveConfigSource = $LlamaConfigSource
  Write-Warn "Company model unavailable at $CompanyApiBase. Falling back to bundled llama.cpp configuration."
} else {
  Write-Info "Company model reachable at $CompanyApiBase. Using company configuration."
}

$NanobotConfig = Join-Path $DotNanobotDir "config.json"
$UserOverridesFile = Join-Path $DotNanobotDir "user_overrides.json"

# Load user overrides (apiKey etc.) that persist across config template switches.
# These are set by the user via the UI and must NOT be overwritten here.
$userOverrides = @{}
if (Test-Path $UserOverridesFile) {
  try {
    # ConvertFrom-Json -AsHashtable requires PS6+; use PSObject.Properties for PS5.1 compat.
    $jsonObj = [System.IO.File]::ReadAllText($UserOverridesFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
    foreach ($prop in $jsonObj.PSObject.Properties) {
      $userOverrides[$prop.Name] = $prop.Value
    }
  } catch {
    Write-Warn "Could not read user_overrides.json: $($_.Exception.Message)"
  }
}

Copy-TextFileUtf8NoBom $ActiveConfigSource $NanobotConfig
Write-Info "Copied active config to $NanobotConfig from $(Split-Path -Leaf $ActiveConfigSource)"

# Apply user-overridden apiKey from user_overrides.json (survives config template switches).
$overrideApiKey = $userOverrides['apiKey']
if ($overrideApiKey) {
  try {
    $content = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
    $escaped = $overrideApiKey -replace '"', '\"'
    $content = [regex]::Replace($content, '(?s)("custom"\s*:\s*\{[^}]*?"apiKey"\s*:\s*)"[^"]*"', ('${1}"' + $escaped + '"'))
    [System.IO.File]::WriteAllText($NanobotConfig, $content, (New-Object System.Text.UTF8Encoding($false)))
    Write-Info "Applied user apiKey override from user_overrides.json."
  } catch {
    Write-Warn "Failed to apply user apiKey override: $($_.Exception.Message)"
  }
}

# Patch tools.web.proxy from HTTP_PROXY env var into the runtime config.
# Uses regex instead of ConvertFrom-Json to avoid emoji-encoding failures on the
# nanobot-expanded config (e.g. discord channel fields contain emoji literals).
if ($env:HTTP_PROXY) {
  try {
    $content = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
    $proxyUrl = $env:HTTP_PROXY.Trim()
    # Target only tools.web.proxy (not channels.telegram.proxy or others).
    # (?s) = Singleline so .* spans newlines; non-greedy .*? stops at first match.
    $pattern = '(?s)("tools"\s*:\s*\{.*?"web"\s*:\s*\{.*?"proxy"\s*:\s*)null'
    $escaped  = $proxyUrl -replace '"', '\"'
    $replacement = '${1}"' + $escaped + '"'
    $patched = [regex]::Replace($content, $pattern, $replacement, 1)
    [System.IO.File]::WriteAllText($NanobotConfig, $patched, (New-Object System.Text.UTF8Encoding($false)))
    Write-Info "Patched tools.web.proxy from HTTP_PROXY: $proxyUrl"
  } catch {
    Write-Warn "Failed to patch proxy into runtime config: $($_.Exception.Message)"
  }
}

$SkillSourceDir = Join-Path $InstallRoot "skills"
$SkillTargetDir = Join-Path $WorkspaceDir "skills"
Copy-DirectoryIfMissing $SkillSourceDir $SkillTargetDir

# Copy workspace definition files (SOUL.md, AGENTS.md, TOOLS.md, USER.md) from
# workspace-defaults if they do not yet exist in the workspace.  Using
# Copy-DirectoryIfMissing means the user's edits are preserved on subsequent launches.
$WorkspaceDefaultsDir = Join-Path $InstallRoot "workspace-defaults"
Copy-DirectoryIfMissing $WorkspaceDefaultsDir $WorkspaceDir

# Ensure standard workspace subdirectories exist (idempotent — safe to run every launch).
# data/: input files for the agent to read; project/: agent-created projects;
# output/: agent-generated output files.
foreach ($wsSubDir in @("data", "project", "output")) {
  $d = Join-Path $WorkspaceDir $wsSubDir
  if (-not (Test-Path $d)) {
    New-Item -ItemType Directory -Path $d -Force | Out-Null
    Write-Info "Created workspace subdirectory: $d"
  }
}

# Issue 3: After the one-time copy to workspace, remove the source directories
# from the install root so the installation folder stays clean.
# Both directories are idempotently recreated by the installer on upgrade.
foreach ($cleanupDir in @($SkillSourceDir, $WorkspaceDefaultsDir)) {
  if (Test-Path $cleanupDir) {
    try {
      Remove-Item -Path $cleanupDir -Recurse -Force -ErrorAction Stop
      Write-Info "Removed installer source directory: $cleanupDir"
    } catch {
      Write-Warn "Could not remove ${cleanupDir}: $($_.Exception.Message)"
    }
  }
}

# --- Set up isolated Python environment for skills ---
# Ensure-SkillPython returns the isolated python.exe path and prepends its
# Scripts/ (or parent) directory to PATH so that ALL child processes
# — including nanobot agent tool calls like `python` or `pip` — resolve to the
# isolated Python, never to the user's system Python.
$SkillPy = Ensure-SkillPython
if ($SkillPy) {
  # Determine the Scripts directory (embed: parent dir; venv: Scripts subdir)
  $SkillPyDir = Split-Path -Parent $SkillPy
  # Prepend embed Python dir AND its Scripts/ subdir so both `python` and
  # `pip` resolve to the isolated interpreter — not the user's system Python.
  $SkillScriptsDir = Join-Path $SkillPyDir "Scripts"
  $env:PATH               = "$SkillPyDir;$SkillScriptsDir;$env:PATH"
  $env:MEC_PYTHON_EXE     = $SkillPy
  $env:MEC_WHL_DIR        = Join-Path $InstallRoot "whl"
  $env:MEC_PIP_OFFLINE    = "1"
  $env:MEC_SKILLS_DIR     = $WorkspaceDir
  # VIRTUAL_ENV tells pip/tools this Python is isolated (suppresses some warnings)
  $env:VIRTUAL_ENV        = Split-Path -Parent $SkillPyDir
  $env:VIRTUAL_ENV_PROMPT = "mec-skill"
  # Force UTF-8 I/O for all child Python processes (fixes garbled Chinese on CP950)
  $env:PYTHONUTF8         = "1"
  $env:PYTHONIOENCODING   = "utf-8"
  # Create a pip.bat shim in the embed Python dir so bare `pip` resolves to
  # embed Python's pip (embed Python has no pip.exe in Scripts by default).
  $PipShim = Join-Path $SkillPyDir "pip.bat"
  if (-not (Test-Path $PipShim)) {
    "@echo off`r`n`"$SkillPy`" -m pip %*" | Set-Content -Path $PipShim -Encoding ASCII
    Write-Info "Created pip shim: $PipShim"
  }
  Write-Info "Skill Python active: $SkillPy"
  Write-Info "PATH prepended with: $SkillPyDir ; $SkillScriptsDir"
} else {
  Write-Warn "No isolated Python available — skill features requiring Python will not work."
  Write-Warn "Python commands from the agent may use your system Python."
}

# --- Set up bundled Node.js runtime for Playwright MCP ---
# The Playwright MCP server is launched via `node playwright-mcp-bundle.cjs`
# which requires Node.js on PATH. We bundle a portable Node.js in
# bundled\playwright-mcp\runtime\ and add it to PATH.
#
# The Playwright MCP code is shipped as a single esbuild bundle file
# (playwright-mcp-bundle.cjs) plus two small JSON files (package.json,
# browsers.json). This replaces the previous ZIP + node_modules approach
# because antivirus (notably Windows Defender) aggressively deletes .js
# files extracted from ZIPs in node_modules/ directories — observed
# deleting 1200+ files within seconds of extraction.
#
# The config is patched at runtime: "npx" + "@playwright/mcp@X.Y.Z" is
# replaced with "node" + absolute path to the bundle file. This avoids
# npx trying to download the package from the internet.
$PwNodeRuntimeDir = Join-Path $InstallRoot "bundled\playwright-mcp\runtime"
if (Test-Path $PwNodeRuntimeDir) {
  $env:PATH = "$PwNodeRuntimeDir;$env:PATH"
  Write-Info "Playwright MCP Node.js runtime added to PATH: $PwNodeRuntimeDir"

  # Point Playwright to the bundled Chromium browser directory.
  # Required for html2pptx.js (pptx skill) which uses Playwright to render HTML slides.
  $PwBrowserDir = Join-Path $InstallRoot "bundled\playwright-mcp\browser"
  if (Test-Path $PwBrowserDir) {
    $env:PLAYWRIGHT_BROWSERS_PATH = $PwBrowserDir
    Write-Info "Playwright browsers path set: $PwBrowserDir"
  }

  # Set NODE_PATH so Node.js can find globally-installed modules
  # (pptxgenjs, playwright, sharp, etc.) required by skills like pptx.
  $PwNodeModulesDir = Join-Path $PwNodeRuntimeDir "node_modules"
  if (Test-Path $PwNodeModulesDir) {
    $env:NODE_PATH = $PwNodeModulesDir
    Write-Info "NODE_PATH set: $PwNodeModulesDir"
  }

  # Patch nanobot config: replace "npx" + "@playwright/mcp@X.Y.Z" with
  # "node" + absolute path to the bundle file. This avoids npx trying
  # to download the package when CWD doesn't contain the node_modules.
  #
  # We use simple string replacement (not regex) because the playwright
  # server block may span many lines with `}` chars in string values
  # (e.g. user-agent) that confuse `[^}]*?` regex patterns.
  $PwBundle = Join-Path $InstallRoot "bundled\playwright-mcp\bundle\playwright-mcp-bundle.cjs"
  if ((Test-Path $PwBundle) -and (Test-Path $NanobotConfig)) {
    try {
      # Normalize backslashes to forward slashes for JSON safety
      $PwBundleJson = $PwBundle -replace '\\', '/'
      $cfgContent = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
      $pwArgMarker = '"@playwright/mcp@'
      if ($cfgContent.Contains($pwArgMarker)) {
        # Replace command: "command": "npx" → "command": "node"
        # In our config, npx is only used by the playwright MCP server.
        # Global replace is safe here — any MCP server using npx to launch
        # a bundled package should be converted to direct node invocation.
        $cfgContent = $cfgContent.Replace('"command": "npx"', '"command": "node"')
        # Replace the first arg: "@playwright/mcp@X.Y.Z" → "path/to/bundle.cjs"
        $oldArgStart = $cfgContent.IndexOf($pwArgMarker)
        if ($oldArgStart -ge 0) {
          # Find the closing quote of this arg value
          $oldArgEnd = $cfgContent.IndexOf('"', $oldArgStart + $pwArgMarker.Length)
          if ($oldArgEnd -ge 0) {
            $cfgContent = $cfgContent.Substring(0, $oldArgStart) + '"' + $PwBundleJson + $cfgContent.Substring($oldArgEnd)
          }
        }
        $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
        [System.IO.File]::WriteAllText($NanobotConfig, $cfgContent, $utf8NoBom)
        Write-Info "Patched Playwright MCP in config: node $PwBundleJson"
      }
    } catch {
      Write-Warn "Failed to patch Playwright MCP config: $($_.Exception.Message)"
    }
  }
} else {
  Write-Info "No bundled Playwright MCP runtime found — Playwright MCP requires system Node.js."
}

# Delete the whl cache after packages have been installed (save disk space; idempotent on next launch).
$WhlCacheDir = Join-Path $InstallRoot "whl"
if (Test-Path $WhlCacheDir) {
  try {
    Remove-Item -Path $WhlCacheDir -Recurse -Force -ErrorAction Stop
    Write-Info "Removed whl cache after package installation: $WhlCacheDir"
  } catch {
    Write-Warn "Could not remove whl cache ${WhlCacheDir}: $($_.Exception.Message)"
  }
}

$LlamaProcess = $null
if ($UseLlamaRuntime) {
  # --- Launch llama.cpp ---
  $llamaExe = Ensure-LlamaRuntime $InstallRoot
  $ModelPath = Resolve-ModelPath $ModelPath $RuntimeRoot $InstallRoot $DotNanobotDir $ModelFile
  if (-not $ModelPath) {
    Write-Error "Model file not found. Set NANOBOT_MODEL_PATH or re-run the installer to configure the model path."
    exit 1
  }
  Write-TextFileUtf8NoBom $ModelPathFile $ModelPath
  # Patch the runtime config's model name to match the actual resolved model filename.
  # Uses instance Regex.Replace(input, replacement, count=1) to replace only the first
  # "model": "..." occurrence (agents.defaults.model), not any other model-named fields.
  $resolvedModelName = [System.IO.Path]::GetFileName($ModelPath)
  try {
    $content = [System.IO.File]::ReadAllText($NanobotConfig, [System.Text.Encoding]::UTF8)
    $escaped = $resolvedModelName -replace '\\', '\\\\' -replace '"', '\"'
    $rx = New-Object System.Text.RegularExpressions.Regex(
      '("model"\s*:\s*)"[^"]*"',
      [System.Text.RegularExpressions.RegexOptions]::Multiline
    )
    $patched = $rx.Replace($content, ('${1}"' + $escaped + '"'), 1)
    [System.IO.File]::WriteAllText($NanobotConfig, $patched, (New-Object System.Text.UTF8Encoding($false)))
    Write-Info "Runtime config model name set to: $resolvedModelName (from $ModelPath)"
  } catch {
    Write-Warn "Failed to patch model name in runtime config: $($_.Exception.Message)"
  }
  $LlamaProcess = Start-LlamaBackground $llamaExe $ModelPath $LlamaPort $LlamaDir (Join-Path $InstallRoot "logs\llama_server")
  if ($LlamaProcess) {
    Write-Info "llama-server started in background on port $LlamaPort. Model loading — status will appear in control panel."
  }
}

# Locate Ein-Wiki folder (bundled; user launches via the MEC Agent UI button).
$MecWikiDir = Join-Path $InstallRoot "bundled\ein-wiki"

# Default vault path for Ein-Wiki (LLM-wiki structure: raw/ + wiki/ + AGENTS.md).
# Uses the same Documents folder as the installer (NanobotSetup.iss).
# Override by setting $env:MEC_WIKI_VAULT before launching, or editing this line.
if ($env:MEC_WIKI_VAULT) {
  $WikiVaultPath = $env:MEC_WIKI_VAULT
} else {
  $WikiVaultPath = Join-Path $BaseDir "Mec-wiki"
}

# --- Launch API server (OpenAI-compatible /v1/chat/completions) ---
$ApiServerProcess = $null
$ApiServerExe    = Join-Path $InstallRoot "api_server\api_server.exe"
$ApiServerScript = Join-Path $InstallRoot "api_server\server.py"
$ApiServerLogDir = Join-Path $InstallRoot "logs"
if (-not (Test-Path $ApiServerLogDir)) { New-Item -ItemType Directory -Path $ApiServerLogDir | Out-Null }
$ApiServerLogPrefix = Join-Path $ApiServerLogDir "api_server"

# Common extra args passed to both exe and script modes.
# --mec-wiki enables the UI button for launching Ein-Wiki.
# --mec-wiki-vault sets the default vault (passed to node as --vault <path>).
$ApiServerExtraArgs = @('--mec-wiki', $MecWikiDir, '--mec-wiki-vault', $WikiVaultPath)

if (Test-TcpPortOpen 47391) {
  Write-Info "api_server already listening on port 47391 — reusing existing instance."
} elseif (Test-Path $ApiServerExe) {
  $stdoutLog = "$ApiServerLogPrefix.out.log"
  $stderrLog = "$ApiServerLogPrefix.err.log"
  $outputDir = Split-Path -Parent $stdoutLog
  if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
  $apiArgs = @('--config', $NanobotConfig, '--workspace', $WorkspaceDir, '--port', '47391', '--parent-pid', $PID) + $ApiServerExtraArgs
  Write-Info "Starting api_server (exe) on http://127.0.0.1:47391 (logs: $stdoutLog)"
  $ApiServerProcess = Start-Process -FilePath $ApiServerExe `
    -ArgumentList $apiArgs -WindowStyle Hidden `
    -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru
} elseif (Test-Path $ApiServerScript) {
  $PythonExe = Find-Python
  if ($PythonExe) {
    $stdoutLog = "$ApiServerLogPrefix.out.log"
    $stderrLog = "$ApiServerLogPrefix.err.log"
    $outputDir = Split-Path -Parent $stdoutLog
    if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null }
    $apiArgs = @($ApiServerScript, '--config', $NanobotConfig, '--workspace', $WorkspaceDir, '--port', '47391', '--parent-pid', $PID) + $ApiServerExtraArgs
    Write-Info "Starting api_server (script) on http://127.0.0.1:47391 (logs: $stdoutLog)"
    $ApiServerProcess = Start-Process -FilePath $PythonExe `
      -ArgumentList $apiArgs -WindowStyle Hidden `
      -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru
  } else {
    Write-Warn "api_server.exe not found and Python not available — API server will be unavailable."
  }
} else {
  Write-Warn "api_server not found ($ApiServerExe) — /v1/chat/completions will be unavailable."
}

# --- Launch MEC Agent gateway in background (hidden, no cmd window) ---
$NanobotWrapperPath = Join-Path $InstallRoot "mec_agent.cmd"
Write-NanobotWrapper $NanobotWrapperPath $NanobotExe $NanobotConfig $WorkspaceDir

$WebUiUrl = Get-WebUiUrlFromConfig $NanobotConfig
$WebUiUri = [Uri]$WebUiUrl
$NanobotLogPrefix = Join-Path $InstallRoot "logs\mec_agent_gateway"

Write-Info "Starting MEC Agent gateway in background..."
$NanobotStdout = "$NanobotLogPrefix.out.log"
$NanobotStderr = "$NanobotLogPrefix.err.log"
$NanobotLogDir = Split-Path -Parent $NanobotStdout
if (-not (Test-Path $NanobotLogDir)) { New-Item -ItemType Directory -Path $NanobotLogDir | Out-Null }

# Use System.Diagnostics.Process so we can intercept stderr and replace
# "nanobot" → "mec_agent" in each line before writing to the log file.
$_nbPsi = New-Object System.Diagnostics.ProcessStartInfo
$_nbPsi.FileName               = "cmd.exe"
$_nbPsi.Arguments              = "/c call `"$NanobotWrapperPath`""
$_nbPsi.WorkingDirectory       = $InstallRoot
$_nbPsi.UseShellExecute        = $false
$_nbPsi.RedirectStandardOutput = $true
$_nbPsi.RedirectStandardError  = $true
$_nbPsi.CreateNoWindow         = $true
$NanobotProcess = New-Object System.Diagnostics.Process
$NanobotProcess.StartInfo = $_nbPsi
$null = $NanobotProcess.Start()

# Background runspace: stdout → log (raw)
$_nbStdoutPath   = $NanobotStdout
$_nbStdoutStream = $NanobotProcess.StandardOutput.BaseStream
$_nbOutRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$_nbOutRS.Open()
$_nbOutPS = [System.Management.Automation.PowerShell]::Create()
$_nbOutPS.Runspace = $_nbOutRS
[void]$_nbOutPS.AddScript({
  param($src, $dst)
  try {
    $enc    = New-Object System.Text.UTF8Encoding($false)
    $writer = New-Object System.IO.StreamWriter($dst, $false, $enc)
    $reader = New-Object System.IO.StreamReader($src, $enc)
    while (-not $reader.EndOfStream) {
      $line = $reader.ReadLine()
      if ($null -ne $line) { $writer.WriteLine($line); $writer.Flush() }
    }
  } finally { try { $writer.Close() } catch {} }
}).AddArgument($_nbStdoutStream).AddArgument($_nbStdoutPath)
$null = $_nbOutPS.BeginInvoke()

# Background runspace: stderr → log, replacing "nanobot" → "mec_agent"
$_nbStderrPath   = $NanobotStderr
$_nbStderrStream = $NanobotProcess.StandardError.BaseStream
$_nbErrRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$_nbErrRS.Open()
$_nbErrPS = [System.Management.Automation.PowerShell]::Create()
$_nbErrPS.Runspace = $_nbErrRS
[void]$_nbErrPS.AddScript({
  param($src, $dst)
  try {
    $enc    = New-Object System.Text.UTF8Encoding($false)
    $writer = New-Object System.IO.StreamWriter($dst, $false, $enc)
    $reader = New-Object System.IO.StreamReader($src, $enc)
    while (-not $reader.EndOfStream) {
      $line = $reader.ReadLine()
      if ($null -ne $line) {
        $writer.WriteLine(($line -replace 'nanobot', ''))
        $writer.Flush()
      }
    }
  } finally { try { $writer.Close() } catch {} }
}).AddArgument($_nbStderrStream).AddArgument($_nbStderrPath)
$null = $_nbErrPS.BeginInvoke()

# --- Show control panel window (WinForms) --- Tomcat-style UI
# This window is the only visible UI while nanobot runs in background.
try {
  Add-Type -AssemblyName System.Windows.Forms
  Add-Type -AssemblyName System.Drawing

  # ── Helper: create a separator line ──────────────────────────────────────
  function New-Separator([int]$x, [int]$y, [int]$width) {
    $sep = New-Object System.Windows.Forms.Panel
    $sep.Location  = New-Object System.Drawing.Point($x, $y)
    $sep.Size      = New-Object System.Drawing.Size($width, 1)
    $sep.BackColor = [System.Drawing.Color]::FromArgb(213, 217, 223)
    return $sep
  }

  # ── Helper: create a label pair (key | value) at a given y offset ─────────
  function New-InfoRow([System.Windows.Forms.Panel]$parent, [string]$key, [string]$value, [int]$y) {
    $lk = New-Object System.Windows.Forms.Label
    $lk.Text      = $key
    $lk.Font      = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
    $lk.ForeColor = [System.Drawing.Color]::FromArgb(85, 95, 110)
    $lk.Location  = New-Object System.Drawing.Point(20, $y)
    $lk.Size      = New-Object System.Drawing.Size(130, 20)
    $lv = New-Object System.Windows.Forms.Label
    $lv.Text      = $value
    $lv.Font      = New-Object System.Drawing.Font("Segoe UI", 9)
    $lv.ForeColor = [System.Drawing.Color]::FromArgb(30, 40, 55)
    $lv.Location  = New-Object System.Drawing.Point(155, $y)
    $lv.Size      = New-Object System.Drawing.Size(350, 20)
    $parent.Controls.AddRange(@($lk, $lv))
    return $lv
  }

  # ── Main form ─────────────────────────────────────────────────────────────
  $form = New-Object System.Windows.Forms.Form
  $form.Text            = "MEC Agent v$AppVersion — Control Panel"
  # ClientSize gives the exact drawable area (excludes title-bar + borders).
  # Expand height by 26 px when llama runtime is active (extra service row).
  # Expand height by 80 px for Connectivity section (2 rows: External Network + Pega AI).
  $BASE_CH = 500
  $CH      = if ($UseLlamaRuntime) { $BASE_CH + 26 } else { $BASE_CH }
  $form.ClientSize      = New-Object System.Drawing.Size(560, $CH)
  $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog
  $form.MaximizeBox     = $false
  $form.StartPosition   = [System.Windows.Forms.FormStartPosition]::CenterScreen
  $form.BackColor       = [System.Drawing.Color]::White
  $iconFile = Join-Path $InstallRoot "mec_agent.ico"
  if (Test-Path $iconFile) { $form.Icon = New-Object System.Drawing.Icon($iconFile) }

  # Layout constants (all panels use explicit Location + Size; no Dock = no z-order surprises)
  $HDR_H  = 68   # header
  $ACC_H  = 4    # accent strip
  $FTR_H  = 68   # footer
  $W      = 560
  $CONT_Y = $HDR_H + $ACC_H          # 72
  $CONT_H = $CH - $CONT_Y - $FTR_H   # 280 (non-llama) or 306 (llama)
  $FTR_Y  = $CH - $FTR_H             # 352 (non-llama) or 378 (llama)

  # ── Header banner (dark navy, Tomcat-style) ────────────────────────────────
  $pnlHeader = New-Object System.Windows.Forms.Panel
  $pnlHeader.Location  = New-Object System.Drawing.Point(0, 0)
  $pnlHeader.Size      = New-Object System.Drawing.Size($W, $HDR_H)
  $pnlHeader.BackColor = [System.Drawing.Color]::FromArgb(28, 42, 64)

  $lblBrand = New-Object System.Windows.Forms.Label
  $lblBrand.Text      = "MEC Agent v$AppVersion"
  $lblBrand.ForeColor = [System.Drawing.Color]::White
  $lblBrand.Font      = New-Object System.Drawing.Font("Segoe UI", 18, [System.Drawing.FontStyle]::Bold)
  $lblBrand.AutoSize  = $true
  $lblBrand.Location  = New-Object System.Drawing.Point(16, 8)

  $lblSubtitle = New-Object System.Windows.Forms.Label
  $lblSubtitle.Text      = "Control Panel  |  Enterprise AI Gateway"
  $lblSubtitle.ForeColor = [System.Drawing.Color]::FromArgb(170, 190, 215)
  $lblSubtitle.Font      = New-Object System.Drawing.Font("Segoe UI", 8.5)
  $lblSubtitle.AutoSize  = $true
  $lblSubtitle.Location  = New-Object System.Drawing.Point(18, 44)

  $pnlHeader.Controls.AddRange(@($lblBrand, $lblSubtitle))

  # ── Orange accent strip (like Tomcat's red bar) ────────────────────────────
  $pnlAccent = New-Object System.Windows.Forms.Panel
  $pnlAccent.Location  = New-Object System.Drawing.Point(0, $HDR_H)
  $pnlAccent.Size      = New-Object System.Drawing.Size($W, $ACC_H)
  $pnlAccent.BackColor = [System.Drawing.Color]::FromArgb(212, 93, 36)

  # ── Content panel (explicit size, no Dock) ────────────────────────────────
  $pnlContent = New-Object System.Windows.Forms.Panel
  $pnlContent.Location  = New-Object System.Drawing.Point(0, $CONT_Y)
  $pnlContent.Size      = New-Object System.Drawing.Size($W, $CONT_H)
  $pnlContent.BackColor = [System.Drawing.Color]::White

  # Section: "Server Information"
  $lblInfoTitle = New-Object System.Windows.Forms.Label
  $lblInfoTitle.Text      = "Server Information"
  $lblInfoTitle.Font      = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
  $lblInfoTitle.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $lblInfoTitle.AutoSize  = $true
  $lblInfoTitle.Location  = New-Object System.Drawing.Point(20, 14)
  $pnlContent.Controls.Add($lblInfoTitle)
  $pnlContent.Controls.Add((New-Separator 16 36 528))

  # Info rows — status starts as "Starting..."
  $lvStatus = New-InfoRow $pnlContent "Status" "⏳ Starting..." 46
  $lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
  $lvStatus.Font      = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)

  $null = New-InfoRow $pnlContent "Web UI"      $WebUiUrl                   74
  $null = New-InfoRow $pnlContent "API Server"  "http://127.0.0.1:47391/"   102
  $null = New-InfoRow $pnlContent "Install Dir" $InstallRoot                130

  $pnlContent.Controls.Add((New-Separator 16 158 528))

  # Section: "Services"
  $lblSvcTitle = New-Object System.Windows.Forms.Label
  $lblSvcTitle.Text      = "Services"
  $lblSvcTitle.Font      = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
  $lblSvcTitle.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $lblSvcTitle.AutoSize  = $true
  $lblSvcTitle.Location  = New-Object System.Drawing.Point(20, 168)
  $pnlContent.Controls.Add($lblSvcTitle)
  $pnlContent.Controls.Add((New-Separator 16 190 528))

  $lGw = New-InfoRow $pnlContent "MEC Agent Gateway" "⏳ Starting...  (port 8765 / 18790)" 200
  $lGw.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
  $lApi = New-InfoRow $pnlContent "API Server" "⏳ Starting...  (port 47391)" 226
  $lApi.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)

  $script:lLlama = $null
  if ($UseLlamaRuntime) {
    $script:lLlama = New-InfoRow $pnlContent "Llama Server" "⏳ Loading model...  (port $LlamaPort)" 252
    $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
  }

  # Section: "連線檢測 Connectivity"
  $connYBase = if ($UseLlamaRuntime) { 278 } else { 252 }
  $lblConnTitle = New-Object System.Windows.Forms.Label
  $lblConnTitle.Text      = "連線檢測  Connectivity"
  $lblConnTitle.Font      = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
  $lblConnTitle.ForeColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $lblConnTitle.AutoSize  = $true
  $lblConnTitle.Location  = New-Object System.Drawing.Point(20, $connYBase)
  $pnlContent.Controls.Add($lblConnTitle)
  $pnlContent.Controls.Add((New-Separator 16 ($connYBase + 22) 528))

  $lExtNet = New-InfoRow $pnlContent "外網權限" "⏳ 等待 API Server..." ($connYBase + 32)
  $lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
  $lPegaAi = New-InfoRow $pnlContent "Pega AI" "⏳ 等待 API Server..." ($connYBase + 58)
  $lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)

  $script:extNetTested = $false
  $script:pegaAiTested = $false
  $script:extNetJob    = $null
  $script:pegaAiJob    = $null

  # ── Footer action panel ─────────────────────────────────────────────────────
  $pnlFooter = New-Object System.Windows.Forms.Panel
  $pnlFooter.Location  = New-Object System.Drawing.Point(0, $FTR_Y)
  $pnlFooter.Size      = New-Object System.Drawing.Size($W, $FTR_H)
  $pnlFooter.BackColor = [System.Drawing.Color]::FromArgb(245, 246, 249)

  $footerBorder = New-Object System.Windows.Forms.Panel
  $footerBorder.Dock      = [System.Windows.Forms.DockStyle]::Top
  $footerBorder.Height    = 1
  $footerBorder.BackColor = [System.Drawing.Color]::FromArgb(210, 214, 220)
  $pnlFooter.Controls.Add($footerBorder)

  # [Open Web UI] button
  $btnOpen = New-Object System.Windows.Forms.Button
  $btnOpen.Text      = "Open Web UI"
  $btnOpen.Size      = New-Object System.Drawing.Size(138, 40)
  $btnOpen.Location  = New-Object System.Drawing.Point(16, 14)
  $btnOpen.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
  $btnOpen.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $btnOpen.BackColor  = [System.Drawing.Color]::White
  $btnOpen.ForeColor  = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $btnOpen.Font       = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
  $btnOpen.Cursor     = [System.Windows.Forms.Cursors]::Hand
  $btnOpen.add_Click({ Start-Process $WebUiUrl | Out-Null })

  # [Stop Service] button
  $btnStop = New-Object System.Windows.Forms.Button
  $btnStop.Text      = "Stop Service"
  $btnStop.Size      = New-Object System.Drawing.Size(118, 40)
  $btnStop.Location  = New-Object System.Drawing.Point(166, 14)
  $btnStop.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
  $btnStop.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
  $btnStop.BackColor  = [System.Drawing.Color]::FromArgb(210, 47, 38)
  $btnStop.ForeColor  = [System.Drawing.Color]::White
  $btnStop.Font       = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
  $btnStop.Cursor     = [System.Windows.Forms.Cursors]::Hand
  $script:stopClicked = $false
  $script:restartRequested = $false
  $btnStop.add_Click({
    $script:stopClicked = $true
    $script:notifyIcon.Visible = $false
    $script:notifyIcon.Dispose()
    $form.Close()
    [System.Windows.Forms.Application]::ExitThread()
  })

  # [Restart] button — directly signals launcher to restart (no API call needed)
  $btnRestart = New-Object System.Windows.Forms.Button
  $btnRestart.Text      = "Restart Agent"
  $btnRestart.Size      = New-Object System.Drawing.Size(128, 40)
  $btnRestart.Location  = New-Object System.Drawing.Point(296, 14)
  $btnRestart.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
  $btnRestart.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(100, 130, 180)
  $btnRestart.BackColor  = [System.Drawing.Color]::FromArgb(240, 244, 252)
  $btnRestart.ForeColor  = [System.Drawing.Color]::FromArgb(28, 42, 64)
  $btnRestart.Font       = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
  $btnRestart.Cursor     = [System.Windows.Forms.Cursors]::Hand
  $btnRestart.add_Click({
    # Signal restart directly in the launcher — no API call needed.
    # The finally block kills child processes; then the script relaunches.
    $script:restartRequested = $true
    $script:stopClicked = $true
    $script:notifyIcon.Visible = $false
    try { $script:notifyIcon.Dispose() } catch {}
    $form.Close()
    [System.Windows.Forms.Application]::ExitThread()
  })

  # Status hint (bottom-right, smaller)
  $lblHint = New-Object System.Windows.Forms.Label
  $lblHint.Text      = "Double-click tray icon to restore"
  $lblHint.ForeColor = [System.Drawing.Color]::FromArgb(150, 160, 170)
  $lblHint.Font      = New-Object System.Drawing.Font("Segoe UI", 7)
  $lblHint.AutoSize  = $true
  $lblHint.Location  = New-Object System.Drawing.Point(440, 52)

  $pnlFooter.Controls.AddRange(@($btnOpen, $btnStop, $btnRestart, $lblHint))

  # Clicking X (close box) hides to system tray instead of closing.
  # When stopClicked is true, the form closes and Application.ExitThread()
  # is called by the button/restart handlers to end the message loop.
  $form.add_FormClosing({
    param($s, $e)
    if (-not $script:stopClicked -and $e.CloseReason -eq [System.Windows.Forms.CloseReason]::UserClosing) {
      $e.Cancel = $true
      $s.Hide()
    }
  })

  # ── System tray NotifyIcon ─────────────────────────────────────────────────
  # Visible from launch — the control panel starts hidden and lives in the tray.
  # Double-click or context-menu "Restore" brings the panel to the desktop.
  $script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
  $script:notifyIcon.Text    = "MEC Agent v$AppVersion — Starting..."
  $script:notifyIcon.Visible = $true
  if (Test-Path $iconFile) {
    $script:notifyIcon.Icon = New-Object System.Drawing.Icon($iconFile)
  }

  # Context menu for tray icon
  $trayMenu = New-Object System.Windows.Forms.ContextMenuStrip
  $trayOpenWeb = $trayMenu.Items.Add("Open Web UI")
  $trayRestart = $trayMenu.Items.Add("Restart Agent")
  $trayStop    = $trayMenu.Items.Add("Stop Service")
  $trayOpenWeb.add_Click({
    Start-Process $WebUiUrl | Out-Null
  })
  $trayRestart.add_Click({
    $script:restartRequested = $true
    $script:stopClicked = $true
    $script:notifyIcon.Visible = $false
    try { $script:notifyIcon.Dispose() } catch {}
    $form.Close()
    [System.Windows.Forms.Application]::ExitThread()
  })
  $trayStop.add_Click({
    $script:stopClicked = $true
    $script:notifyIcon.Visible = $false
    $script:notifyIcon.Dispose()
    $form.Close()
    [System.Windows.Forms.Application]::ExitThread()
  })
  $script:notifyIcon.ContextMenuStrip = $trayMenu

  # Double-click tray icon → restore
  $script:notifyIcon.add_DoubleClick({
    $form.Show()
    $form.WindowState = [System.Windows.Forms.FormWindowState]::Normal
    $form.BringToFront()
  })

  # BalloonTip click → open Web UI (so the user can act on the reminder)
  $script:notifyIcon.add_BalloonTipClicked({
    Start-Process $WebUiUrl | Out-Null
  })

  # ── Mini HTTP listener for notification balloons (port 47392) ─────────────
  # The api_server POSTs to http://127.0.0.1:47392/notify when a cron
  # reminder fires.  This listener runs on a background thread and
  # invokes ShowBalloonTip on the WinForms thread so the balloon appears
  # even when the web UI is closed.
  $script:httpListener = $null
  try {
    $script:httpListener = [System.Net.HttpListener]::new()
    $script:httpListener.Prefixes.Add("http://127.0.0.1:47392/")
    $script:httpListener.Start()
    Write-Info "Notification listener started on http://127.0.0.1:47392/"

    $notifyListenerScript = {
      param($listener, $icon)
      while ($listener.IsListening) {
        try {
          $ctx = $null
          $ctx = $listener.GetContext()
          $reader = [System.IO.StreamReader]::new($ctx.Request.InputStream, [System.Text.Encoding]::UTF8)
          $body = $reader.ReadToEnd()
          $reader.Close()

          $payload = $body | ConvertFrom-Json -ErrorAction SilentlyContinue
          $title   = if ($payload.title)   { $payload.title }   else { 'MEC Agent' }
          $msg     = if ($payload.message) { $payload.message } else { '' }

          # ShowBalloonTip must run on the UI thread — use .Invoke
          $icon.GetType().InvokeMember(
            'ShowBalloonTip',
            [System.Reflection.BindingFlags]::InvokeMethod,
            $null,
            $icon,
            @(5000, $title, $msg, [System.Windows.Forms.ToolTipIcon]::Info)
          )

          # Respond 200 OK
          $ctx.Response.StatusCode = 200
          $buf = [System.Text.Encoding]::UTF8.GetBytes('{"ok":true}')
          $ctx.Response.ContentType = 'application/json'
          $ctx.Response.ContentLength64 = $buf.Length
          $ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
          $ctx.Response.OutputStream.Close()
        } catch {
          if ($listener.IsListening) {
            # Individual request error — keep listening
          }
        }
      }
    }

    $notifyListenerThread = [System.Threading.Thread]::new({
      param($state)
      $args = $state -split '\|'
      # Pass listener and icon references via closure
      & $notifyListenerScript $script:httpListener $script:notifyIcon
    })
    $notifyListenerThread.IsBackground = $true
    $notifyListenerThread.SetApartmentState([System.Threading.ApartmentState]::STA)
    $notifyListenerThread.Start()
  } catch {
    Write-Warn "Could not start notification listener on port 47392: $_"
    $script:httpListener = $null
  }

  # ── Form starts hidden in the system tray ──────────────────────────────────
  # Set Visible=false before the message loop so the control panel does NOT
  # appear on the desktop at launch.  The NotifyIcon (above) is already visible
  # in the tray, so the user can double-click it to show the panel.
  $form.Visible = $false
  $form.Controls.AddRange(@($pnlContent, $pnlFooter, $pnlAccent, $pnlHeader))

  # ── Poll gateway / API-server readiness on UI thread (non-blocking TCP check) ──
  # Quick TCP probe: WaitOne(350) blocks UI thread at most 350 ms per tick.
  $script:gwReady  = $false
  $script:apiReady = $false
  $script:llamaReady = $true   # treat as ready if not using llama runtime
  if ($UseLlamaRuntime) { $script:llamaReady = $false }
  $script:browserOpened = $false
  $script:llamaPollTicks   = 0      # incremented each timer tick while llama port is not yet open
  $script:llamaCpuFallback = $false # true once GPU times out and CPU fallback is initiated
  $script:llamaCpuJob      = $null  # background Start-Job: extracts CPU zip + starts llama-server

  $pollTimer = New-Object System.Windows.Forms.Timer
  $pollTimer.Interval = 1000
  $pollTimer.add_Tick({
    # --- helper: attempt TCP connect with short timeout ---
    function _TcpOpen([int]$port) {
      $ok  = $false
      $tcp = $null
      try {
        $tcp = New-Object System.Net.Sockets.TcpClient
        $ar  = $tcp.BeginConnect('127.0.0.1', $port, $null, $null)
        $ok  = $ar.AsyncWaitHandle.WaitOne(350)
      } catch {}
      if ($tcp) { try { $tcp.Close() } catch {} }
      return $ok
    }

    if (-not $script:gwReady -and (_TcpOpen 18790)) {
      $script:gwReady = $true
      $lvStatus.Text      = '● Running'
      $lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
      $lGw.Text           = '● Running  (port 8765 / 18790)'
      $lGw.ForeColor      = [System.Drawing.Color]::FromArgb(34, 139, 34)
      $script:notifyIcon.Text = "MEC Agent — Running"
      # Show a balloon tip so the user knows the agent is ready in the tray.
      $script:notifyIcon.ShowBalloonTip(3000, 'MEC Agent', 'MEC Agent 已就緒', [System.Windows.Forms.ToolTipIcon]::Info)
      if (-not $script:browserOpened) {
        $script:browserOpened = $true
        Start-Process $WebUiUrl | Out-Null
        Write-Info "Opened web UI at $WebUiUrl"
      }
    }

    if (-not $script:apiReady -and (_TcpOpen 47391)) {
      $script:apiReady = $true
      $lApi.Text      = '● Running  (port 47391)'
      $lApi.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
    }

    # ── Connectivity checks (run once after API server is ready) ──────────────
    if ($script:apiReady -and -not $script:extNetTested) {
      $script:extNetTested = $true
      $lExtNet.Text = '⏳ 測試中...'
      $lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
      # Run proxy test via API server endpoint
      $script:extNetJob = Start-Job -ScriptBlock {
        try {
          $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/proxy' -TimeoutSec 5 -ErrorAction Stop
          if (-not $resp.hasProxy) {
            return @{ ok = $false; reason = 'not_configured'; ms = 0 }
          }
          $test = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/proxy/test' -Method Post -TimeoutSec 15 -ErrorAction Stop
          if ($test.success) {
            return @{ ok = $true; reason = ''; ms = $test.responseTimeMs }
          } else {
            return @{ ok = $false; reason = 'test_failed'; ms = 0 }
          }
        } catch {
          return @{ ok = $false; reason = 'exception'; ms = 0 }
        }
      }
    }
    # Check ext net job result
    if ($script:extNetJob -and $script:extNetJob.State -ne 'Running') {
      $extResult = Receive-Job $script:extNetJob -ErrorAction SilentlyContinue
      Remove-Job $script:extNetJob -Force -ErrorAction SilentlyContinue
      $script:extNetJob = $null
      if ($extResult) {
        # Map job result codes to Chinese display text
        $resultCode = $extResult.ok
        $resultMs   = $extResult.ms
        if ($resultCode -eq $true) {
          $lExtNet.Text      = "● 已連線 ($resultMs`ms)"
          $lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
        } else {
          $failReason = $extResult.reason
          if ($failReason -eq 'not_configured') {
            $lExtNet.Text = '✗ 未設定 — 請至設定中進行 Proxy 設定'
          } elseif ($failReason -eq 'test_failed') {
            $lExtNet.Text = '✗ 連線失敗 — 請至設定中確認 Proxy 設定'
          } else {
            $lExtNet.Text = '✗ 測試失敗 — 請至設定中進行 Proxy 設定'
          }
          $lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
        }
      } else {
        $lExtNet.Text = '✗ 測試失敗 — 請至設定中進行 Proxy 設定'
        $lExtNet.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
      }
    }

    if ($script:apiReady -and -not $script:pegaAiTested) {
      $script:pegaAiTested = $true
      $lPegaAi.Text = '⏳ 測試中...'
      $lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(170, 120, 0)
      # Run Pega AI test via API server endpoint
      $script:pegaAiJob = Start-Job -ScriptBlock {
        try {
          $status = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/pegaai/status' -TimeoutSec 5 -ErrorAction Stop
          if (-not $status.configured) {
            return @{ ok = $false; reason = 'not_configured'; ms = 0 }
          }
          $test = Invoke-RestMethod -Uri 'http://127.0.0.1:47391/api/pegaai/test' -Method Post -TimeoutSec 30 -ErrorAction Stop
          if ($test.success) {
            return @{ ok = $true; reason = ''; ms = $test.responseTimeMs }
          } else {
            return @{ ok = $false; reason = 'test_failed'; ms = 0 }
          }
        } catch {
          return @{ ok = $false; reason = 'exception'; ms = 0 }
        }
      }
    }
    # Check Pega AI job result
    if ($script:pegaAiJob -and $script:pegaAiJob.State -ne 'Running') {
      $pegaResult = Receive-Job $script:pegaAiJob -ErrorAction SilentlyContinue
      Remove-Job $script:pegaAiJob -Force -ErrorAction SilentlyContinue
      $script:pegaAiJob = $null
      if ($pegaResult) {
        $resultCode = $pegaResult.ok
        $resultMs   = $pegaResult.ms
        if ($resultCode -eq $true) {
          $lPegaAi.Text      = "● 已連線 ($resultMs`ms)"
          $lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
        } else {
          $failReason = $pegaResult.reason
          if ($failReason -eq 'not_configured') {
            $lPegaAi.Text = '✗ 未設定 — 請至設定中進行 Pega AI 設定'
          } elseif ($failReason -eq 'test_failed') {
            $lPegaAi.Text = '✗ 連線失敗 — 請至設定中確認 Pega AI 設定'
          } else {
            $lPegaAi.Text = '✗ 測試失敗 — 請至設定中進行 Pega AI 設定'
          }
          $lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
        }
      } else {
        $lPegaAi.Text = '✗ 測試失敗 — 請至設定中進行 Pega AI 設定'
        $lPegaAi.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
      }
    }

    if (-not $script:llamaReady -and $script:lLlama) {

      # ── Phase 2: waiting for background CPU-fallback job (extract zip + start) ──
      if ($script:llamaCpuFallback -and $script:llamaCpuJob) {
        if ($script:llamaCpuJob.State -ne 'Running') {
          $cpuPid = Receive-Job $script:llamaCpuJob -Wait -ErrorAction SilentlyContinue
          Remove-Job $script:llamaCpuJob -Force -ErrorAction SilentlyContinue
          $script:llamaCpuJob = $null
          if ($cpuPid -and [int]$cpuPid -gt 0) {
            # Reassign $script:LlamaProcess to the new CPU server process
            $script:LlamaProcess = Get-Process -Id ([int]$cpuPid) -ErrorAction SilentlyContinue
            $script:lLlama.Text  = '⏳ CPU fallback started — loading model...'
          } else {
            $script:llamaReady = $true
            $script:lLlama.Text      = '⚠ CPU fallback failed to start; check logs\llama_server_cpu.err.log'
            $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
            $lvStatus.Text      = '⚠ Degraded (CPU fallback failed)'
            $lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
            $script:notifyIcon.Text = 'MEC Agent — llama.cpp failed'
          }
        }
        # Skip TCP-open check this tick — wait for next tick
      } else {
        # ── Phase 1 (GPU attempt) or Phase 3 (CPU process now running) ────────
        # After CPU fallback setup, $script:LlamaProcess was reassigned to the CPU process.
        # Reading $LlamaProcess here finds the script-scope variable (CPU or GPU).
        if ($LlamaProcess -and $LlamaProcess.HasExited) {
          # Process exited without opening the port — crash / backend init failure.
          $errLog = if ($script:llamaCpuFallback) { 'logs\llama_server_cpu.err.log' } else { 'logs\llama_server.err.log' }
          $tag    = if ($script:llamaCpuFallback) { 'CPU fallback' } else { 'llama.cpp' }
          $script:llamaReady = $true
          $script:lLlama.Text      = "✗ $tag crashed (exit $($LlamaProcess.ExitCode)) — check $errLog"
          $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
          $lvStatus.Text      = "⚠ Degraded ($tag crashed)"
          $lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
          $script:notifyIcon.Text = "MEC Agent — $tag crashed"
        } elseif (_TcpOpen $LlamaPort) {
          $portSuffix = if ($script:llamaCpuFallback) { " (CPU fallback, port $LlamaPort)" } else { "  (port $LlamaPort)" }
          $script:llamaReady = $true
          $script:lLlama.Text      = "● Running$portSuffix"
          $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(34, 139, 34)
        } else {
          $script:llamaPollTicks++
          # At 5 s, update hint — may indicate a slow or stuck GPU init.
          if ($script:llamaPollTicks -eq 5) {
            $script:lLlama.Text = '⏳ Still loading…  taking longer than expected'
          }
          # GPU variant: timeout after 10 s (small models load in < 5 s; 10 s = definitely stuck).
          # CPU fallback: timeout after 60 s (CPU inference loading is genuinely slower).
          if ($script:llamaPollTicks -ge (if ($script:llamaCpuFallback) { 60 } else { 10 })) {
            if ($script:llamaCpuFallback) {
              # ── CPU also timed out → final failure ────────────────────────
              $script:llamaReady = $true
              $script:lLlama.Text      = '⚠ CPU fallback timeout — check logs\llama_server_cpu.err.log'
              $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
              $lvStatus.Text      = '⚠ Degraded (llama.cpp CPU fallback timeout)'
              $lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
              $script:notifyIcon.Text = 'MEC Agent — llama.cpp failed'
              if ($LlamaProcess -and -not $LlamaProcess.HasExited) {
                try { Stop-Process -Id $LlamaProcess.Id -Force -ErrorAction SilentlyContinue } catch {}
              }
            } else {
              # ── GPU timed out → kill and attempt CPU fallback ─────────────
              # Typical symptom: process alive but stuck at "load_backend: loaded RPC backend".
              if ($LlamaProcess -and -not $LlamaProcess.HasExited) {
                try { Stop-Process -Id $LlamaProcess.Id -Force -ErrorAction SilentlyContinue } catch {}
              }
              $arch    = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
              $zipDir  = Join-Path $InstallRoot 'llama'
              $cpuZipPath = Get-ChildItem -Path $zipDir -Filter "llama-b*-bin-win-cpu-$arch.zip" `
                              -File -ErrorAction SilentlyContinue |
                            Sort-Object Name -Descending |
                            Select-Object -First 1 -ExpandProperty FullName
              if (-not $cpuZipPath) {
                # No CPU zip bundled — show error immediately
                $script:llamaReady = $true
                $script:lLlama.Text      = '⚠ GPU timeout — no CPU fallback zip found in llama\'
                $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(180, 40, 30)
                $lvStatus.Text      = '⚠ Degraded (llama.cpp timeout)'
                $lvStatus.ForeColor = [System.Drawing.Color]::FromArgb(180, 120, 0)
                $script:notifyIcon.Text = 'MEC Agent — llama.cpp timeout'
              } else {
                $script:llamaCpuFallback = $true
                $script:llamaPollTicks   = 0
                $script:lLlama.Text      = '⏳ GPU timed out — switching to CPU...'
                $script:lLlama.ForeColor = [System.Drawing.Color]::FromArgb(200, 140, 0)
                # Launch background job: extract CPU zip → start llama-server → return PID.
                # Running asynchronously avoids blocking the UI thread during extraction.
                $cpuDestDir = Join-Path $LlamaDir 'cpu-fallback'
                $mPath = $ModelPath
                $lPort = $LlamaPort
                $lOut  = Join-Path $InstallRoot 'logs\llama_server_cpu.out.log'
                $lErr  = Join-Path $InstallRoot 'logs\llama_server_cpu.err.log'
                $script:llamaCpuJob = Start-Job -ScriptBlock {
                  param($zip, $dest, $model, $port, $out, $err)
                  if (-not (Test-Path $dest)) {
                    try { Expand-Archive -Path $zip -DestinationPath $dest -Force }
                    catch { return -1 }
                  }
                  $exe = Get-ChildItem -Path $dest -Filter 'llama-server.exe' -Recurse -File |
                         Select-Object -First 1
                  if (-not $exe) { return -1 }
                  $logDir = Split-Path -Parent $out
                  if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }
                  $p = Start-Process -FilePath $exe.FullName `
                    -ArgumentList @('-m', $model, '--port', $port) `
                    -WorkingDirectory $exe.DirectoryName `
                    -WindowStyle Hidden `
                    -RedirectStandardOutput $out `
                    -RedirectStandardError $err `
                    -PassThru
                  return $p.Id
                } -ArgumentList $cpuZipPath, $cpuDestDir, $mPath, $lPort, $lOut, $lErr
              }
            }
          }
        }
      }
    }

    if ($script:gwReady -and $script:apiReady -and $script:llamaReady -and $script:extNetTested -and $script:pegaAiTested) {
      # All services up and connectivity checks completed — stop fast polling.
      # Note: connectivity job results may still be pending; they are checked
      # on the next tick. Keep the timer running until jobs finish.
      $allJobsDone = (-not $script:extNetJob -or $script:extNetJob.State -ne 'Running') -and
                     (-not $script:pegaAiJob -or $script:pegaAiJob.State -ne 'Running')
      if ($allJobsDone) {
        $lvStatus.Text = '● Running'
        $pollTimer.Stop()
      }
    }
  })
  $pollTimer.Start()

  # Signal the startup splash to close now that the main form is ready.
  $script:splashCloseEvent.Set()

  # ── Restart signal watcher — polls for update/_restart_signal written by api_server ──
  # Runs throughout the session so management-backend restart buttons work reliably.
  # Clean up any stale signal left from a previous failed restart attempt.
  $RestartSignalFile = Join-Path $InstallRoot "update\_restart_signal"
  Remove-Item $RestartSignalFile -Force -ErrorAction SilentlyContinue

  $restartWatcherTimer = New-Object System.Windows.Forms.Timer
  $restartWatcherTimer.Interval = 1000
  $restartWatcherTimer.add_Tick({
    if (Test-Path $RestartSignalFile) {
      Remove-Item $RestartSignalFile -Force -ErrorAction SilentlyContinue
      $script:restartRequested = $true
      $script:stopClicked = $true
      $script:notifyIcon.Visible = $false
      try { $script:notifyIcon.Dispose() } catch {}
      $form.Close()
      [System.Windows.Forms.Application]::ExitThread()
    }
  })
  $restartWatcherTimer.Start()

  # Application.Run() without a form argument starts the message loop WITHOUT
  # auto-showing the form.  The form stays hidden (Visible=false set above)
  # until the user double-clicks the tray icon.
  [System.Windows.Forms.Application]::Run()
} finally {
  # Always clean up the NotifyIcon — if it is not disposed the tray icon persists.
  if ($script:notifyIcon) {
    try { $script:notifyIcon.Visible = $false; $script:notifyIcon.Dispose() } catch {}
  }
  # Stop the notification HTTP listener
  if ($script:httpListener) {
    try { $script:httpListener.Stop() } catch {}
  }
  if ($NanobotProcess -and -not $NanobotProcess.HasExited) {
    Write-Info "Stopping MEC Agent gateway (process tree)..."
    try {
      # /T kills the process AND all its descendant children (e.g. mec_agent.exe spawned by cmd.exe)
      & taskkill /T /F /PID $NanobotProcess.Id 2>$null | Out-Null
    } catch { }
  }
  if ($ApiServerProcess -and -not $ApiServerProcess.HasExited) {
    Write-Info "Stopping api_server (process tree)..."
    try { & taskkill /T /F /PID $ApiServerProcess.Id 2>$null | Out-Null } catch { }
  }
  if ($LlamaProcess -and -not $LlamaProcess.HasExited) {
    Write-Info "Stopping llama.cpp..."
    try { Stop-Process -Id $LlamaProcess.Id -Force -ErrorAction SilentlyContinue } catch { }
  }
  Release-SingleInstanceMutex
}

Write-Info "MEC Agent stopped."

# Relaunch if restart was requested (from Restart Agent button or API signal)
if ($script:restartRequested) {
  Write-Info "Relaunching MEC Agent..."
  $relaunchVbs = Join-Path $InstallRoot "launcher\launch_mec_agent.vbs"
  if (Test-Path $relaunchVbs) {
    Start-Process "wscript.exe" -ArgumentList "`"$relaunchVbs`"" -WorkingDirectory $InstallRoot
  } else {
    Start-Process "powershell.exe" -ArgumentList "-ExecutionPolicy", "Bypass", "-NoProfile", "-WindowStyle", "Hidden", "-File", "`"$PSCommandPath`"" -WorkingDirectory $InstallRoot
  }
}







Login to like - 0 Likes



Comments...


No Comments Yet...



Add Comment...



shumin

A graduated biotechnology engineer. Now is a software engineer


Latest Posts



Footer with Icons