# NDC AI Workspace Bridge installer for Windows.
# Run from an elevated PowerShell session:
# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-windows.ps1

[CmdletBinding()]
param(
  [int]$Port = 8787,
  [string]$InstallRoot = "$env:LOCALAPPDATA\NDC\AIWorkspaceBridge",
  [string]$Workspace = "",
  [string]$HubUrl = "",
  [string]$HubFallbackUrls = "",
  [string]$PairingCode = "",
  [string]$MachineName = "",
  [string]$OpsMcpUrl = "",
  [string]$OpsMcpToken = "",
  [string]$OpsMcpServerName = "nodedc_tasker",
  [string]$AppMcpServersJson = "",
  [switch]$NoAutostart,
  [switch]$NoFirewall,
  [switch]$NoCodexLogin,
  [switch]$EnableWatchdog = $true,
  [switch]$Confirmed
)

$ErrorActionPreference = 'Stop'
$TaskName = 'NDC AI Workspace Bridge'
$HealthPath = '/api/ai-workspace/bridge/v1/health'
$ConversationPath = '/api/ai-workspace/bridge/v1/conversations'

function Get-ResultPath {
  if ($PSScriptRoot -and (Test-Path $PSScriptRoot)) {
    return (Join-Path $PSScriptRoot 'install-result.txt')
  }
  return (Join-Path $InstallRoot 'install-result.txt')
}

function Write-InstallResult {
  param([string]$Text)
  $resultPath = Get-ResultPath
  try {
    $resultDir = Split-Path -Parent $resultPath
    if ($resultDir) { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null }
    Set-Content -Path $resultPath -Value $Text -Encoding UTF8
    Write-Host ""
    Write-Host "Result file: $resultPath" -ForegroundColor Green
  } catch {
    Write-Warn "Could not write result file: $($_.Exception.Message)"
  }
}

function Write-Step {
  param([string]$Text)
  Write-Host ""
  Write-Host "== $Text ==" -ForegroundColor Cyan
}

function Write-Ok {
  param([string]$Text)
  Write-Host "[ok] $Text" -ForegroundColor Green
}

function Write-Warn {
  param([string]$Text)
  Write-Host "[warn] $Text" -ForegroundColor Yellow
}

function Test-Admin {
  $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
  $principal = New-Object Security.Principal.WindowsPrincipal($identity)
  return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Refresh-Path {
  $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
  $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
  $env:Path = "$machinePath;$userPath"
}

function Add-UserPath {
  param([string]$PathToAdd)
  if (-not $PathToAdd -or -not (Test-Path $PathToAdd)) { return }
  $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
  $parts = @()
  if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ } }
  if ($parts -notcontains $PathToAdd) {
    $nextPath = ($parts + $PathToAdd) -join ';'
    [Environment]::SetEnvironmentVariable('Path', $nextPath, 'User')
  }
  Refresh-Path
}

function Resolve-CommandSource {
  param([string]$Name)
  $command = Get-Command $Name -ErrorAction SilentlyContinue
  if ($command) { return $command.Source }
  return $null
}

function Get-NpmPath {
  $command = Get-Command 'npm.cmd' -ErrorAction SilentlyContinue
  if (-not $command) { $command = Get-Command 'npm' -ErrorAction SilentlyContinue }
  if ($command) { return $command.Source }
  return $null
}

function Invoke-NpmInstallCodex {
  $npmPath = Get-NpmPath
  if (-not $npmPath) { throw 'npm was not found.' }
  & $npmPath 'install' '-g' '@openai/codex' '--no-audit' '--no-fund'
  if ($LASTEXITCODE -ne 0) {
    throw "npm install -g @openai/codex exited with code $LASTEXITCODE"
  }
}

function Get-NpmGlobalPrefix {
  $npmPath = Get-NpmPath
  if (-not $npmPath) { throw 'npm was not found.' }
  $prefix = (& $npmPath 'prefix' '-g') -join ''
  if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) {
    throw "npm prefix -g exited with code $LASTEXITCODE"
  }
  return $prefix.Trim()
}

function Get-NpmVersion {
  $npmPath = Get-NpmPath
  if (-not $npmPath) { return $null }
  return ((& $npmPath '--version') -join '').Trim()
}

function ConvertTo-PsSingleQuoted {
  param([string]$Value)
  return "'" + ($Value -replace "'", "''") + "'"
}

function Get-HubUrlCandidates {
  $items = @()
  if ($HubUrl) { $items += $HubUrl }
  if ($HubFallbackUrls) {
    $items += ($HubFallbackUrls -split '[,;\r\n]+' | Where-Object { $_ })
  }
  $result = @()
  foreach ($item in $items) {
    $clean = ([string]$item).Trim()
    if (-not $clean) { continue }
    if ($result -notcontains $clean) { $result += $clean }
  }
  return @($result)
}

function Test-BridgeWorkspaceCandidate {
  param([string]$PathValue)
  if (-not $PathValue) { return $false }
  try {
    $leaf = Split-Path -Leaf $PathValue
    if ($leaf -and $leaf.Equals('ai-workspace-bridge', [StringComparison]::OrdinalIgnoreCase)) { return $true }
    if ($InstallRoot -and $PathValue.StartsWith($InstallRoot, [StringComparison]::OrdinalIgnoreCase)) { return $true }
  } catch {}
  return $false
}

function Resolve-WorkspaceCandidate {
  param([string]$PathValue)
  if (-not $PathValue) { return $null }
  $candidate = $PathValue.Trim('"')
  if (-not (Test-Path $candidate)) { return $candidate }
  if (-not (Test-BridgeWorkspaceCandidate $candidate)) { return $candidate }
  try {
    $parent = Split-Path -Parent $candidate
    if ($parent -and (Test-Path $parent)) { return $parent }
  } catch {}
  return $candidate
}

function Get-DefaultWorkspaceCandidate {
  if ($Workspace) { return $Workspace.Trim('"') }

  try {
    $location = Get-Location
    if ($location.Provider.Name -eq 'FileSystem' -and (Test-Path $location.ProviderPath)) {
      $windowsRoot = [Environment]::GetFolderPath('Windows')
      if (-not $windowsRoot -or -not $location.ProviderPath.StartsWith($windowsRoot, [StringComparison]::OrdinalIgnoreCase)) {
        return (Resolve-WorkspaceCandidate $location.ProviderPath)
      }
    }
  } catch {}

  if ($PSCommandPath) {
    $scriptDir = Split-Path -Parent $PSCommandPath
    if ($scriptDir -and (Test-Path $scriptDir)) { return (Resolve-WorkspaceCandidate $scriptDir) }
  }

  return $env:USERPROFILE
}

function Request-Elevation {
  if (Test-Admin) { return }
  if (-not $PSCommandPath) {
    throw 'This installer needs Administrator rights when pasted directly. Save it as install-windows.ps1 and run it again.'
  }

  Write-Warn 'Administrator rights are needed for Node install and Windows Firewall rule.'
  $workspaceForElevated = Get-DefaultWorkspaceCandidate
  $args = @(
    '-NoProfile',
    '-NoExit',
    '-ExecutionPolicy', 'Bypass',
    '-File', "`"$PSCommandPath`"",
    '-Port', $Port,
    '-InstallRoot', "`"$InstallRoot`"",
    '-Workspace', "`"$workspaceForElevated`"",
    '-Confirmed'
  )
  if ($NoAutostart) { $args += '-NoAutostart' }
  if ($NoFirewall) { $args += '-NoFirewall' }
  if ($NoCodexLogin) { $args += '-NoCodexLogin' }
  if ($EnableWatchdog) { $args += '-EnableWatchdog' }
  if ($HubUrl) { $args += @('-HubUrl', "`"$HubUrl`"") }
  if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") }
  if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") }
  if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") }
  if ($OpsMcpUrl) { $args += @('-OpsMcpUrl', "`"$OpsMcpUrl`"") }
  if ($OpsMcpToken) { $args += @('-OpsMcpToken', "`"$OpsMcpToken`"") }
  if ($OpsMcpServerName) { $args += @('-OpsMcpServerName', "`"$OpsMcpServerName`"") }
  if ($AppMcpServersJson) { $args += @('-AppMcpServersJson', (ConvertTo-PsSingleQuoted $AppMcpServersJson)) }

  Write-Host 'Opening an Administrator PowerShell window. Approve the Windows UAC prompt and continue there.' -ForegroundColor Yellow
  Start-Process -FilePath 'powershell.exe' -ArgumentList ($args -join ' ') -Verb RunAs -Wait
  Write-Host 'Administrator installer window finished. You can close this window.' -ForegroundColor Green
  exit 0
}

function Install-NodeWithWinget {
  $winget = Resolve-CommandSource 'winget'
  if (-not $winget) { return $false }

  Write-Step 'Installing Node.js LTS with winget'
  & $winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements
  if ($LASTEXITCODE -eq 0) {
    Refresh-Path
    return $true
  }

  Write-Warn 'winget did not finish successfully; falling back to direct Node.js MSI.'
  return $false
}

function Install-NodeWithMsi {
  Write-Step 'Installing Node.js LTS from nodejs.org'
  $arch = 'win-x64-msi'
  if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') {
    $arch = 'win-arm64-msi'
  } elseif ($env:PROCESSOR_ARCHITECTURE -eq 'x86' -and -not $env:PROCESSOR_ARCHITEW6432) {
    $arch = 'win-x86-msi'
  }

  $index = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json'
  $release = $index | Where-Object { $_.lts -ne $false -and $_.files -contains $arch } | Select-Object -First 1
  if (-not $release) { throw "Could not find a Node.js LTS MSI for $arch" }

  $tempDir = Join-Path $env:TEMP 'ndc-ai-workspace-bridge'
  New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
  $msi = Join-Path $tempDir "node-$($release.version)-$arch.msi"
  $url = "https://nodejs.org/dist/$($release.version)/node-$($release.version)-$arch.msi"
  Invoke-WebRequest -Uri $url -OutFile $msi

  $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$msi`" /qn /norestart" -Wait -PassThru
  if ($process.ExitCode -ne 0) {
    throw "Node.js MSI failed with code $($process.ExitCode)"
  }
  Refresh-Path
}

function Ensure-Node {
  Write-Step 'Checking Node.js'
  Refresh-Path
  if (Resolve-CommandSource 'node') {
    Write-Ok "Node found: $(& node --version)"
  } else {
    if (-not (Install-NodeWithWinget)) {
      Install-NodeWithMsi
    }
  }

  Refresh-Path
  if (-not (Resolve-CommandSource 'node')) { throw 'Node.js was not found after installation.' }
  if (-not (Get-NpmPath)) { throw 'npm was not found after Node.js installation.' }
  Write-Ok "npm found: $(Get-NpmVersion)"
}

function Ensure-Codex {
  Write-Step 'Checking Codex CLI'
  Refresh-Path
  if (-not (Resolve-CommandSource 'codex')) {
    Invoke-NpmInstallCodex
    Refresh-Path
  }

  $npmPrefix = Get-NpmGlobalPrefix
  Add-UserPath $npmPrefix
  Refresh-Path

  if (-not (Resolve-CommandSource 'codex')) {
    $codexCmd = Join-Path $npmPrefix 'codex.cmd'
    if (Test-Path $codexCmd) {
      Add-UserPath $npmPrefix
    }
  }

  if (-not (Resolve-CommandSource 'codex')) {
    throw 'Codex CLI was installed, but codex is not available in PATH.'
  }

  try {
    $version = (& codex --version) -join ' '
    Write-Ok "Codex found: $version"
  } catch {
    Write-Ok 'Codex found.'
  }
}

function Ensure-CodexLogin {
  if ($NoCodexLogin) {
    Write-Warn 'Codex login skipped by flag.'
    return
  }

  $authFile = Join-Path $env:USERPROFILE '.codex\auth.json'
  if (Test-Path $authFile) {
    Write-Ok 'Codex auth file found.'
    return
  }

  Write-Step 'Codex login'
  Write-Host 'A browser login flow may open. Finish it, then return to this window.'
  $loginOk = $false
  try {
    & codex login
    if ($LASTEXITCODE -eq 0) { $loginOk = $true }
  } catch {
    $loginOk = $false
  }

  if (-not $loginOk) {
    try {
      & codex --login
      if ($LASTEXITCODE -eq 0) { $loginOk = $true }
    } catch {
      $loginOk = $false
    }
  }

  if (-not $loginOk -and -not (Test-Path $authFile)) {
    Write-Warn 'Codex login did not complete. The bridge will start, but messages will fail until Codex is logged in.'
  }
}

function Remove-TomlComment {
  param([string]$Line)
  $text = [string]$Line
  $inSingle = $false
  $inDouble = $false
  $escaped = $false
  for ($i = 0; $i -lt $text.Length; $i++) {
    $char = $text[$i]
    if ($escaped) {
      $escaped = $false
      continue
    }
    if ($inDouble -and $char -eq '\') {
      $escaped = $true
      continue
    }
    if ((-not $inDouble) -and $char -eq "'") {
      $inSingle = -not $inSingle
      continue
    }
    if ((-not $inSingle) -and $char -eq '"') {
      $inDouble = -not $inDouble
      continue
    }
    if ((-not $inSingle) -and (-not $inDouble) -and $char -eq '#') {
      return $text.Substring(0, $i)
    }
  }
  return $text
}

function Normalize-TomlKey {
  param([string]$Line)
  return ((Remove-TomlComment $Line) -replace '\s', '').Replace('"', '').Replace("'", '')
}

function Normalize-TomlHeader {
  param([string]$Line)
  $text = (Remove-TomlComment $Line).Trim()
  if (-not $text.StartsWith('[') -or -not $text.EndsWith(']')) { return '' }
  return Normalize-TomlKey $text
}

function Has-OpsAgentInlineTransport {
  param([string]$Line)
  $key = Normalize-TomlKey $Line
  return ($key -match '^nodedc-ops-agent=\{.*transport=')
}

function Repair-CodexMcpConfig {
  $configPath = Join-Path $env:USERPROFILE '.codex\config.toml'
  if (-not (Test-Path $configPath)) { return }

  try {
    $raw = Get-Content -Path $configPath -Raw -Encoding UTF8
  } catch {
    Write-Warn "Could not read Codex config: $($_.Exception.Message)"
    return
  }

  $next = $raw
  $changed = $false

  $lines = $next -split '\r?\n'
  $inMcpServers = $false
  $inOpsAgent = $false
  $headersChanged = $false
  $requiredChanged = $false
  $transportChanged = $false
  $hasHttpHeaders = $false
  $commentDuplicateHeadersSection = $false
  foreach ($line in $lines) {
    $headerKey = Normalize-TomlHeader $line
    if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.http_headers]') {
      $hasHttpHeaders = $true
      break
    }
  }
  for ($i = 0; $i -lt $lines.Count; $i++) {
    $headerKey = Normalize-TomlHeader $lines[$i]
    if ($commentDuplicateHeadersSection) {
      if ($headerKey.StartsWith('[')) {
        $commentDuplicateHeadersSection = $false
      } else {
        if ($lines[$i].Trim() -and -not $lines[$i].TrimStart().StartsWith('#')) {
          $lines[$i] = '# ' + $lines[$i]
          $headersChanged = $true
        }
        continue
      }
    }
    if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.headers]') {
      if (-not $hasHttpHeaders) {
        $lines[$i] = '[mcp_servers.nodedc-ops-agent.http_headers]'
        $headersChanged = $true
        $hasHttpHeaders = $true
      } else {
        $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: http_headers already exists'
        $headersChanged = $true
        $commentDuplicateHeadersSection = $true
      }
      $inOpsAgent = $false
      $inMcpServers = $false
      continue
    }
    if ($headerKey -eq '[mcp_servers.nodedc-ops-agent]') {
      $inOpsAgent = $true
      $inMcpServers = $false
      continue
    }
    if ($headerKey -eq '[mcp_servers]') {
      $inMcpServers = $true
      $inOpsAgent = $false
      continue
    }
    if (($inOpsAgent -or $inMcpServers) -and $headerKey.StartsWith('[')) {
      $inOpsAgent = $false
      $inMcpServers = $false
    }
    $activeLine = Remove-TomlComment $lines[$i]
    if ($inOpsAgent -and $activeLine -match '^(\s*required\s*=\s*)true(\s*)$') {
      $lines[$i] = $Matches[1] + 'false' + $Matches[2]
      $requiredChanged = $true
    }
    if ($inOpsAgent -and $activeLine -match '^\s*transport\s*=') {
      $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex infers transport from url'
      $transportChanged = $true
    }
    if ($inMcpServers -and (Has-OpsAgentInlineTransport $activeLine)) {
      $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex rejects transport here'
      $transportChanged = $true
    }
  }
  if ($headersChanged -or $requiredChanged -or $transportChanged) {
    $next = $lines -join ([Environment]::NewLine)
    $changed = $true
    if ($headersChanged) {
      Write-Ok 'Codex MCP headers normalized for nodedc-ops-agent.'
    }
    if ($requiredChanged) {
      Write-Ok 'Codex MCP nodedc-ops-agent marked optional for bridge sessions.'
    }
    if ($transportChanged) {
      Write-Ok 'Codex MCP transport normalized for nodedc-ops-agent.'
    }
  }

  if (-not $changed) { return }

  try {
    $backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
    Copy-Item -Path $configPath -Destination $backupPath -Force
    Write-Ok "Codex config backup created: $backupPath"
    Set-Content -Path $configPath -Value $next -Encoding UTF8
  } catch {
    Write-Warn "Could not update Codex config: $($_.Exception.Message)"
  }
}

function ConvertTo-TomlString {
  param([string]$Value)
  $escaped = ([string]$Value).Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n')
  return '"' + $escaped + '"'
}

function ConvertTo-TomlKey {
  param([string]$Key)
  $text = ([string]$Key).Trim()
  if ($text -match '^[A-Za-z0-9_-]+$') { return $text }
  return ConvertTo-TomlString $text
}

function Get-SafeMcpServerName {
  param([string]$ServerName)
  $candidate = ([string]$ServerName).Trim()
  if ($candidate -match '^[A-Za-z0-9_-]+$') { return $candidate }
  return 'nodedc_tasker'
}

function Remove-CodexMcpServerSections {
  param(
    [string]$Raw,
    [string]$ServerName
  )
  if (-not $Raw) { return "" }

  $mainHeader = "[mcp_servers.$ServerName]"
  $headersHeader = "[mcp_servers.$ServerName.headers]"
  $httpHeadersHeader = "[mcp_servers.$ServerName.http_headers]"
  $lines = $Raw -split '\r?\n'
  $result = New-Object System.Collections.Generic.List[string]
  $skip = $false
  $inMcpServers = $false

  foreach ($line in $lines) {
    $headerKey = Normalize-TomlHeader $line
    if ($headerKey.StartsWith('[')) {
      $skip = ($headerKey -eq $mainHeader -or $headerKey -eq $headersHeader -or $headerKey -eq $httpHeadersHeader)
      $inMcpServers = ($headerKey -eq '[mcp_servers]')
      if ($skip) { continue }
    }

    $activeLine = Remove-TomlComment $line
    if ($inMcpServers -and $activeLine -match ("^\s*" + [regex]::Escape($ServerName) + "\s*=")) {
      continue
    }
    if (-not $skip) {
      $result.Add($line) | Out-Null
    }
  }

  return ($result -join ([Environment]::NewLine)).TrimEnd()
}

function Ensure-McpServerConfig {
  param(
    [string]$ServerName,
    [string]$McpUrl,
    [object]$Headers,
    [bool]$Required = $false,
    [int]$StartupTimeoutSec = 20,
    [int]$ToolTimeoutSec = 60,
    [string]$Label = "AI Workspace"
  )

  $serverName = Get-SafeMcpServerName $ServerName
  $mcpUrl = ([string]$McpUrl).Trim()
  if (-not $serverName -or -not $mcpUrl) { return }

  $configDir = Join-Path $env:USERPROFILE '.codex'
  $configPath = Join-Path $configDir 'config.toml'
  New-Item -ItemType Directory -Force -Path $configDir | Out-Null

  $raw = ""
  if (Test-Path $configPath) {
    try {
      $raw = Get-Content -Path $configPath -Raw -Encoding UTF8
    } catch {
      Write-Warn "Could not read Codex config for MCP setup: $($_.Exception.Message)"
      return
    }
  }

  $next = Remove-CodexMcpServerSections -Raw $raw -ServerName $serverName
  $requiredValue = if ($Required) { "true" } else { "false" }
  $startupTimeout = [Math]::Max(1, [int]$StartupTimeoutSec)
  $toolTimeout = [Math]::Max(1, [int]$ToolTimeoutSec)
  $sectionLines = New-Object System.Collections.Generic.List[string]
  $sectionLines.Add("") | Out-Null
  $sectionLines.Add("[mcp_servers.$serverName]") | Out-Null
  $sectionLines.Add("url = $(ConvertTo-TomlString $mcpUrl)") | Out-Null
  $sectionLines.Add("enabled = true") | Out-Null
  $sectionLines.Add("required = $requiredValue") | Out-Null
  $sectionLines.Add("startup_timeout_sec = $startupTimeout") | Out-Null
  $sectionLines.Add("tool_timeout_sec = $toolTimeout") | Out-Null

  $headerLines = New-Object System.Collections.Generic.List[string]
  if ($Headers) {
    foreach ($property in $Headers.PSObject.Properties) {
      $headerName = ([string]$property.Name).Trim()
      $headerValue = ([string]$property.Value).Trim()
      if (-not $headerName -or -not $headerValue) { continue }
      $headerLines.Add("$(ConvertTo-TomlKey $headerName) = $(ConvertTo-TomlString $headerValue)") | Out-Null
    }
  }
  if ($headerLines.Count -gt 0) {
    $sectionLines.Add("") | Out-Null
    $sectionLines.Add("[mcp_servers.$serverName.http_headers]") | Out-Null
    foreach ($line in $headerLines) { $sectionLines.Add($line) | Out-Null }
  }

  $section = $sectionLines -join ([Environment]::NewLine)
  $next = ($next + $section).TrimStart()

  try {
    if (Test-Path $configPath) {
      $backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
      Copy-Item -Path $configPath -Destination $backupPath -Force
      Write-Ok "Codex config backup created: $backupPath"
    }
    Set-Content -Path $configPath -Value $next -Encoding UTF8
    Write-Ok "Codex MCP configured for $Label ($serverName)."
  } catch {
    Write-Warn "Could not update Codex config for MCP server $serverName`: $($_.Exception.Message)"
  }
}

function Ensure-AppMcpConfigs {
  $rawJson = ([string]$AppMcpServersJson).Trim()
  if (-not $rawJson) { return }
  try {
    $servers = $rawJson | ConvertFrom-Json
  } catch {
    Write-Warn "Could not parse AI Workspace app MCP manifest: $($_.Exception.Message)"
    return
  }
  if ($null -eq $servers) { return }
  if ($servers -isnot [System.Array]) { $servers = @($servers) }

  foreach ($server in $servers) {
    if ($null -eq $server) { continue }
    $serverName = [string]$server.serverName
    $mcpUrl = [string]$server.url
    $headers = $server.httpHeaders
    if ($null -eq $headers) { $headers = $server.headers }
    $label = [string]$server.appTitle
    if (-not $label) { $label = [string]$server.appId }
    if (-not $label) { $label = "AI Workspace" }
    $startupTimeout = 20
    $toolTimeout = 60
    try { if ($server.startupTimeoutSec) { $startupTimeout = [int]$server.startupTimeoutSec } } catch {}
    try { if ($server.toolTimeoutSec) { $toolTimeout = [int]$server.toolTimeoutSec } } catch {}
    Ensure-McpServerConfig `
      -ServerName $serverName `
      -McpUrl $mcpUrl `
      -Headers $headers `
      -Required ($server.required -eq $true) `
      -StartupTimeoutSec $startupTimeout `
      -ToolTimeoutSec $toolTimeout `
      -Label $label
  }
}

function Ensure-OpsMcpConfig {
  $mcpUrl = ([string]$OpsMcpUrl).Trim()
  $mcpToken = ([string]$OpsMcpToken).Trim()
  if (-not $mcpUrl -or -not $mcpToken) { return }

  $serverName = Get-SafeMcpServerName $OpsMcpServerName
  $authorization = "Bearer $mcpToken"
  $headers = [pscustomobject]@{
    Authorization = $authorization
    Accept = "application/json"
    "MCP-Protocol-Version" = "2025-06-18"
  }
  Ensure-McpServerConfig -ServerName $serverName -McpUrl $mcpUrl -Headers $headers -Required $false -StartupTimeoutSec 20 -ToolTimeoutSec 60 -Label "AI Workspace Ops"
}

function Get-WorkspacePath {
  $workspacePath = Get-DefaultWorkspaceCandidate
  Write-Ok "Codex workspace: $workspacePath"
  return $workspacePath
}

function Write-WorkerFiles {
  param(
    [string]$Root,
    [string]$WorkspacePath
  )

  Write-Step 'Writing bridge worker'
  $logs = Join-Path $Root 'logs'
  New-Item -ItemType Directory -Force -Path $Root | Out-Null
  New-Item -ItemType Directory -Force -Path $logs | Out-Null
  if (-not (Test-Path $WorkspacePath)) {
    New-Item -ItemType Directory -Force -Path $WorkspacePath | Out-Null
  }

  $workerPath = Join-Path $Root 'worker.mjs'
  $ndcAgentMcpServerPath = Join-Path $Root 'ndcAgentMcpServer.mjs'
  $startScript = Join-Path $Root 'start-bridge.ps1'
  $watchdogScript = Join-Path $Root 'watchdog-bridge.ps1'
  $configPath = Join-Path $Root 'config.json'
  $npmPrefix = Get-NpmGlobalPrefix
  $nodeSource = Resolve-CommandSource 'node'
  $nodeDir = [System.IO.Path]::GetDirectoryName($nodeSource)
  $logPath = Join-Path $logs 'bridge.log'
  $activeMirror = Join-Path $WorkspacePath '.nodedc\ai-workspace'
  $resolvedMachineName = if ($MachineName) { $MachineName } else { $env:COMPUTERNAME }
  $hubUrlCandidates = @(Get-HubUrlCandidates)
  $hubUrlsText = ($hubUrlCandidates -join ',')

  $workerCode = @'
#!/usr/bin/env node
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import fssync from 'node:fs'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'

const HOST = process.env.AI_BRIDGE_HOST || '0.0.0.0'
const PORT = Number(process.env.AI_BRIDGE_PORT || 8787)
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
const SESSION_INDEX = process.env.AI_BRIDGE_SESSION_INDEX || path.join(CODEX_HOME, 'session_index.jsonl')
const SESSIONS_DIR = process.env.AI_BRIDGE_SESSIONS_DIR || path.join(CODEX_HOME, 'sessions')
const CODEX_BIN = process.env.AI_BRIDGE_CODEX_BIN || 'codex'
const DEFAULT_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]'
const CODEX_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_ARGS || DEFAULT_CODEX_ARGS)
const CODEX_RESUME_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_RESUME_ARGS || '')
const CODEX_CWD = process.env.AI_BRIDGE_CODEX_CWD || process.cwd()
const MESSAGE_TIMEOUT_MS = Number(process.env.AI_BRIDGE_MESSAGE_TIMEOUT_MS || 12 * 60 * 60 * 1000)
const RESUME_TIMEOUT_MS = Number(process.env.AI_BRIDGE_RESUME_TIMEOUT_MS || MESSAGE_TIMEOUT_MS)
const MAX_BODY_BYTES = Number(process.env.AI_BRIDGE_MAX_BODY_BYTES || 1024 * 1024)
const MAX_STDIO_BYTES = Number(process.env.AI_BRIDGE_MAX_STDIO_BYTES || 2 * 1024 * 1024)
const MAX_CONVERSATIONS = Number(process.env.AI_BRIDGE_MAX_CONVERSATIONS || 100)
const MAX_SESSION_FILES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILES || 5000)
const MAX_SESSION_FILE_BYTES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILE_BYTES || 64 * 1024 * 1024)
const MAX_MESSAGES_PER_CONVERSATION = Number(process.env.AI_BRIDGE_MAX_MESSAGES_PER_CONVERSATION || 300)
const MAX_MESSAGE_CHARS = Number(process.env.AI_BRIDGE_MAX_MESSAGE_CHARS || 12000)
const MAX_WORKSPACES = Number(process.env.AI_BRIDGE_MAX_WORKSPACES || 120)
const REASONING_SUMMARY_DELTA_MIN_CHARS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MIN_CHARS || 80)
const REASONING_SUMMARY_DELTA_MAX_WAIT_MS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MAX_WAIT_MS || 1800)
const CODEX_STOP_GRACE_MS = Number(process.env.AI_BRIDGE_CODEX_STOP_GRACE_MS || 2500)
const STALE_RUNNING_SESSION_MS = Number(process.env.AI_BRIDGE_STALE_RUNNING_SESSION_MS || 10 * 60 * 1000)
const FINAL_MESSAGE_PHASES = new Set(['final', 'final_answer', 'answer'])
const BRIDGE_FILE = fileURLToPath(import.meta.url)
const BRIDGE_DIR = path.dirname(BRIDGE_FILE)
const NDC_AGENT_CODEX_HOME = path.resolve(process.env.AI_BRIDGE_NDC_AGENT_CODEX_HOME || path.join(BRIDGE_DIR, 'codex-home-ndc-agent-core'))
const RUN_CODEX_HOME_ROOT = path.resolve(process.env.AI_BRIDGE_RUN_CODEX_HOME_ROOT || path.join(BRIDGE_DIR, 'codex-home-runs'))
const DEFAULT_NDC_AGENT_MCP_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
const HUB_URL = String(process.env.AI_BRIDGE_HUB_URL || '').trim()
const HUB_URLS = parseHubUrls(process.env.AI_BRIDGE_HUB_URLS || '', HUB_URL)
const PAIRING_CODE = String(process.env.AI_BRIDGE_PAIRING_CODE || '').trim()
const MACHINE_NAME = String(process.env.AI_BRIDGE_MACHINE_NAME || os.hostname()).trim()
const HUB_RECONNECT_MS = Number(process.env.AI_BRIDGE_HUB_RECONNECT_MS || 5000)
const HUB_CONNECT_TIMEOUT_MS = Number(process.env.AI_BRIDGE_HUB_CONNECT_TIMEOUT_MS || 10000)
const hubState = {
  mode: Boolean(HUB_URLS.length && PAIRING_CODE),
  connected: false,
  url: '',
  pairingCode: PAIRING_CODE,
  candidates: HUB_URLS,
  lastConnectedAt: '',
  lastDisconnectedAt: '',
  lastError: '',
}
const ACTIVE_MIRROR_ENABLED = process.env.AI_BRIDGE_ACTIVE_MIRROR !== '0'
const ACTIVE_MIRROR_DIR = resolveWorkspacePath(process.env.AI_BRIDGE_ACTIVE_MIRROR_DIR || path.join('.nodedc', 'ai-workspace'))
const ACTIVE_MIRROR_CURRENT_FILE = path.join(ACTIVE_MIRROR_DIR, 'current.md')
const ACTIVE_MIRROR_EVENTS_FILE = path.join(ACTIVE_MIRROR_DIR, 'events.jsonl')
const ACTIVE_MIRROR_MAX_EVENTS = Number(process.env.AI_BRIDGE_ACTIVE_MIRROR_MAX_EVENTS || 80)
const ACTIVE_MIRROR_OPEN_IDE = process.env.AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE !== '0'
const ACTIVE_MIRROR_IDE_BIN = String(process.env.AI_BRIDGE_ACTIVE_MIRROR_IDE_BIN || 'code').trim()
const NDC_AGENT_MCP_SERVER = resolveWorkspacePath(defaultNdcAgentMcpServerPath())
const NDC_AGENT_MCP_REF_PATH = process.env.AI_BRIDGE_NDC_AGENT_MCP_REF_PATH ||
  path.resolve(path.dirname(NDC_AGENT_MCP_SERVER), '..', '..', '..', 'tools', 'NDCMCP')
const activeCodexRunsByRequest = new Map()
const activeCodexRunsByThread = new Map()

function defaultNdcAgentMcpServerPath() {
  const explicit = String(process.env.AI_BRIDGE_NDC_AGENT_MCP_SERVER || '').trim()
  if (explicit) return explicit
  const installed = path.join(BRIDGE_DIR, 'ndcAgentMcpServer.mjs')
  if (fssync.existsSync(installed)) return installed
  const cwdLeaf = path.basename(String(CODEX_CWD || '').replace(/[\\/]+$/, ''))
  if (cwdLeaf === 'nodedc-source') return path.join('server', 'aiWorkspace', 'ndcAgentMcpServer.mjs')
  return path.join('nodedc-source', 'server', 'aiWorkspace', 'ndcAgentMcpServer.mjs')
}

function resolveWorkspacePath(value) {
  const text = String(value || '').trim()
  if (!text) return CODEX_CWD
  return path.isAbsolute(text) ? text : path.resolve(CODEX_CWD, text)
}

async function resolveExistingDirectory(value, fallback = CODEX_CWD) {
  const fallbackPath = resolveWorkspacePath(fallback)
  const candidate = resolveWorkspacePath(value || fallback)
  try {
    const stat = await fs.stat(candidate)
    if (stat.isDirectory()) return candidate
  } catch {}
  return fallbackPath
}

function workspaceTitle(value) {
  const normalized = String(value || '').replace(/[\\/]+$/, '')
  return path.basename(normalized) || normalized || 'Workspace'
}

function workspaceId(value) {
  return String(value || '').trim()
}

function parseHubUrls(raw, primary = '') {
  const values = []
  const push = (value) => {
    const text = String(value || '').trim()
    if (!text) return
    try {
      const url = new URL(text)
      if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return
      url.username = ''
      url.password = ''
      const clean = url.toString().replace(/\/+$/, '')
      if (!values.includes(clean)) values.push(clean)
    } catch {}
  }
  push(primary)
  const text = String(raw || '').trim()
  if (!text) return values
  try {
    const parsed = JSON.parse(text)
    if (Array.isArray(parsed)) {
      parsed.forEach(push)
      return values
    }
  } catch {}
  text.split(/[\n,;]+/).forEach(push)
  return values
}

function nowMs() {
  return Date.now()
}

function elapsedLabel(startedAt) {
  const elapsed = Math.max(0, nowMs() - Number(startedAt || nowMs()))
  if (elapsed < 1000) return `${elapsed}ms`
  return `${(elapsed / 1000).toFixed(1)}s`
}

function elapsedFromIso(value) {
  const startedAt = Date.parse(String(value || ''))
  if (!Number.isFinite(startedAt)) return ''
  return elapsedLabel(startedAt)
}

function cleanRunKey(value, limit = 160) {
  return String(value || '').trim().slice(0, limit)
}

function terminateProcessTree(child, signal = 'SIGTERM') {
  if (!child) return
  if (process.platform === 'win32' && child.pid) {
    try {
      const args = ['/PID', String(child.pid), '/T']
      if (signal === 'SIGKILL') args.push('/F')
      const killer = spawn('taskkill', args, {
        windowsHide: true,
        stdio: 'ignore',
        shell: false,
      })
      killer.unref?.()
      return
    } catch {}
  }
  try {
    child.kill(signal)
  } catch {}
}

function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
  const requestId = cleanRunKey(control.requestId, 120)
  const threadId = cleanRunKey(control.threadId, 160)
  if (!requestId && !threadId) return null
  const run = {
    requestId,
    threadId,
    child,
    onEvent,
    closed: false,
    stopRequested: false,
    stopTimer: null,
    stop() {
      if (run.closed) return false
      if (run.stopRequested) return true
      run.stopRequested = true
      try {
        run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' })
      } catch {}
      terminateProcessTree(run.child, 'SIGTERM')
      run.stopTimer = setTimeout(() => {
        if (run.closed) return
        terminateProcessTree(run.child, 'SIGKILL')
      }, CODEX_STOP_GRACE_MS)
      return true
    },
    unregister() {
      run.closed = true
      if (run.stopTimer) clearTimeout(run.stopTimer)
      if (requestId && activeCodexRunsByRequest.get(requestId) === run) activeCodexRunsByRequest.delete(requestId)
      if (threadId && activeCodexRunsByThread.get(threadId) === run) activeCodexRunsByThread.delete(threadId)
    },
  }
  if (requestId) activeCodexRunsByRequest.set(requestId, run)
  if (threadId) activeCodexRunsByThread.set(threadId, run)
  return run
}

function stopActiveCodexRun(payload = {}) {
  const requestId = cleanRunKey(payload?.requestId, 120)
  const threadId = cleanRunKey(payload?.threadId, 160)
  const run = (requestId ? activeCodexRunsByRequest.get(requestId) : null)
    || (threadId ? activeCodexRunsByThread.get(threadId) : null)
    || null
  if (!run) {
    return {
      ok: true,
      stopped: false,
      reason: 'active_codex_run_not_found',
      requestId,
      threadId,
    }
  }
  const stopped = run.stop()
  return {
    ok: true,
    stopped,
    requestId: run.requestId,
    threadId: run.threadId,
  }
}

function parseArgs(raw) {
  const text = String(raw || '').trim()
  if (!text) return []
  try {
    const parsed = JSON.parse(text)
    if (Array.isArray(parsed)) return parsed.map((item) => String(item))
  } catch {}
  return text.split(/\s+/).filter(Boolean)
}

function sendJson(res, status, payload) {
  const body = JSON.stringify(payload)
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store',
    'Access-Control-Allow-Origin': process.env.AI_BRIDGE_ALLOW_ORIGIN || '*',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
  })
  res.end(body)
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    let size = 0
    let body = ''
    req.setEncoding('utf8')
    req.on('data', (chunk) => {
      size += Buffer.byteLength(chunk)
      if (size > MAX_BODY_BYTES) {
        reject(new Error('body_too_large'))
        req.destroy()
        return
      }
      body += chunk
    })
    req.on('end', () => {
      if (!body.trim()) return resolve({})
      try {
        resolve(JSON.parse(body))
      } catch {
        reject(new Error('json_invalid'))
      }
    })
    req.on('error', reject)
  })
}

async function readConversations() {
  let raw = ''
  try {
    raw = await fs.readFile(SESSION_INDEX, 'utf8')
  } catch {
    return []
  }
  const conversations = raw
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter(Boolean)
    .map((line) => {
      try {
        const item = JSON.parse(line)
        const id = String(item.id || '').trim()
        if (!id) return null
        return {
          id,
          title: String(item.thread_name || item.title || id).trim(),
          updatedAt: item.updated_at || item.updatedAt || null,
        }
      } catch {
        return null
      }
    })
    .filter(Boolean)
    .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
    .slice(0, MAX_CONVERSATIONS)

  const sessionFiles = await listSessionFiles(SESSIONS_DIR)
  await Promise.all(conversations.map(async (conversation) => {
    const sessionFile = await findSessionFile(sessionFiles, conversation)
    if (!sessionFile) {
      conversation.messages = []
      return
    }
    conversation.sessionFile = sessionFile
    const workspaceMeta = await readSessionWorkspaceMeta(sessionFile)
    if (workspaceMeta?.cwd) {
      conversation.workspacePath = workspaceMeta.cwd
      conversation.workspaceTitle = workspaceTitle(workspaceMeta.cwd)
      conversation.workspaceUpdatedAt = workspaceMeta.updatedAt || conversation.updatedAt || null
    }
    const sessionState = await readSessionState(sessionFile)
    conversation.messages = sessionState.messages
    conversation.status = sessionState.status
    conversation.running = sessionState.running
    conversation.sessionUpdatedAt = sessionState.updatedAt
  }))

  return conversations
}

function buildPrompt(payload) {
  const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
  const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
  const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {}
  const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object'
    ? toolProfile.assistantActions
    : {}
  const assistantActionIds = Array.isArray(assistantActions.actionIds)
    ? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean)
    : []
  const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim()
  const userMessage = String(payload?.message || '').trim()
  const history = payload?.resume ? [] : normalizeHistory(payload?.history || [])
  const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core'
  const isOpsMode = String(context.modeId || '').trim() === 'ops'
  const guardInstruction = String(context.guardInstruction || payload?.guardInstruction || '').trim()
  const missingContext = Array.isArray(context.missingContext)
    ? context.missingContext.map((item) => String(item || '').trim()).filter(Boolean)
    : []
  const contextReady = context.contextReady !== false && missingContext.length === 0
  const engineContext = context.engineContext && typeof context.engineContext === 'object'
    ? context.engineContext
    : context.contexts?.engine && typeof context.contexts.engine === 'object'
      ? context.contexts.engine
      : {}
  const opsContext = context.opsContext && typeof context.opsContext === 'object'
    ? context.opsContext
    : context.contexts?.ops && typeof context.contexts.ops === 'object'
      ? context.contexts.ops
      : {}
  const ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
  const lines = [
    'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.',
    '',
    'Context:',
    `- active surface: ${context.surface || 'unknown'}`,
    `- source surface: ${context.sourceSurface || context.surface || 'unknown'}`,
    `- mode: ${context.modeTitle || context.modeId || 'unknown'}`,
    `- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`,
    `- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`,
    `- role: ${context.roleTitle || context.roleId || 'unknown'}`,
    `- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`,
    `- context ready: ${contextReady ? 'yes' : 'no'}`,
    ...missingContext.length ? [`- missing context: ${missingContext.join(', ')}`] : [],
    `- access mode: ${context.accessMode || 'chat'}`,
    '',
    'Cross-platform contexts:',
    `- Engine workflow: ${engineContext.workflowTitle || engineContext.workflowId || 'not selected'}`,
    `- Engine agent node: ${engineContext.agentNodeTitle || engineContext.agentNodeId || 'not selected'}`,
    `- Engine role: ${engineContext.roleTitle || engineContext.roleId || 'not selected'}`,
    `- Ops workspace: ${opsContext.opsWorkspaceTitle || opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'not selected'}`,
    `- Ops project: ${opsContext.opsProjectTitle || opsContext.opsProjectIdentifier || opsContext.opsProjectId || 'not selected'}`,
    '- the active/source surface is where the user opened the assistant; it is not a write boundary by itself',
    '- choose the write target from the selected per-app contexts and the user request; cross-app work is allowed when the relevant context is selected',
    '- do not refuse Engine work only because the source surface is Ops, and do not refuse Ops work only because the mode is NDC Agent Core',
    '- use the user language for the final answer and public progress/reasoning summaries; if the user writes in Russian, write them in Russian',
    '- never mention internal n8n/N8N names, endpoints, environment variables, schemas, or runtime identifiers in public answers; call the product and workflow runtime NDC',
    '- while working, provide concise public progress/reasoning summaries when the Codex runtime exposes them',
    '- public progress/reasoning summaries should say what you are checking, reading, comparing, or deciding',
    '- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details',
    ...isNdcAgentCore ? [
      '',
      'NDC Agent Core mode contract:',
      ...!contextReady ? [
        '- The NDC Agent Core context is incomplete. Chat, explanations, reading, and Ops work are still allowed when the required target for that work is selected.',
        '- Do not claim that an Engine agent node is selected when it is missing.',
        '- Do not create, edit, patch, deploy, or write Engine workflow graph changes until the missing Engine agent node is selected.',
        '- If the user asks for Engine development or graph edits, explain briefly that selecting an Engine agent node is required for that specific write action.',
      ] : [
        '- Treat the selected Engine agent node as the only writable Engine workflow target.',
        '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.',
        '- The Engine second-level workflow file is the source of truth for edits.',
        `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`,
        `- Selected workflowId: ${context.workflowId || 'unknown'}`,
        `- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`,
        '- Read the second-level graph with GET /subworkflow?workflowId=<workflowId>&nodeId=<agentNodeId>.',
        '- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.',
        '- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow, assistant_action_call.',
        '- Do not use direct NDC core runtime MCP servers, local workflow files, or shell probes for graph edits in this mode.',
        '- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.',
        '- In public answers, use only NDC labels: NDC workflow, NDC node, NDC node type, NDC nodebase, and NDC Agent Core.',
        '- Do not expose internal vendor names, raw implementation field names, backend schema routes, or internal node class names in public answers.',
        '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.',
        '- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.',
        '- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.',
      ],
      '',
      'Assistant action routing contract:',
      `- Assistant action ids available in this run: ${assistantActionIds.length ? assistantActionIds.join(', ') : 'none'}.`,
      '- For Launcher/HUB/admin/access/users/invites/roles/service grants requests, use only the ndc_agent_core MCP tool assistant_action_call.',
      '- Do not use codex_apps readonly connectors, read_handoff, local files, shell search, logs, or workspace scans for Launcher/HUB live administrative data.',
      '- Use phase="execute" for read-only action calls after selecting the structured action id.',
      '- Use phase="preview" before any privileged/write action, ask for explicit confirmation, then use phase="execute" only after confirmation.',
      '- Useful read action ids when advertised: hub.access_request.list_pending, hub.invite.list_pending, hub.user.read_admin_summary.',
      '- If assistant_action_call is unavailable or the gateway returns an error, report that exact tool/error and stop; do not guess from local files.',
    ] : [],
    ...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [
      '',
      'NODE.DC Ops mode contract:',
      '- Use Ops only inside the selected Ops workspace and project.',
      `- Selected Ops workspace slug: ${opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'unknown'}`,
      `- Selected Ops project id: ${opsContext.opsProjectId || 'unknown'}`,
      `- Selected Ops project identifier: ${opsContext.opsProjectIdentifier || 'unknown'}`,
      '- Prefer the MCP tools exposed for NODE.DC Ops when creating, updating, moving, or reading tasks.',
      '- If assistant action ids include ops.card.list_recent, ops.card.create, or ops.card.add_comment, these are the canonical Ops card actions for this run.',
      '- Use assistant_action_call phase="execute" for Ops card reads after selecting ops.card.list_recent.',
      '- Use assistant_action_call phase="preview" before Ops card create/comment writes, ask for explicit confirmation, then call phase="execute" with the returned confirmation token.',
      '- Before writing Ops tasks, use the Ops MCP project/context tools when available and include a unique idempotency key for write tools.',
      '- If direct Ops MCP tools are unavailable but the Ops assistant action ids are advertised, do not refuse; route through assistant_action_call.',
      '- If neither direct Ops MCP tools nor Ops assistant action ids are available, say that the Ops context is selected but live Ops tools are unavailable.',
      '- Do not claim that an Engine workflow or Engine agent node is required for Ops task writes.',
      '- If the current request is about Ops tasks and Ops workspace/project are selected, treat that as the writable Ops target.',
      '- In public answers, use NODE.DC/Ops labels and never expose internal vendor names.',
    ] : []),
    ...guardInstruction ? [
      '',
      'System context guard:',
      guardInstruction,
    ] : [],
    ...runProfilePolicyPrompt ? [
      '',
      runProfilePolicyPrompt,
    ] : [],
    '',
    ...history.length ? [
      'Recent conversation from AI Workspace:',
      ...history.map((message) => `${message.role}: ${message.text}`),
      '',
    ] : [],
    'User message:',
    userMessage,
  ]
  return lines.join('\n')
}

async function listSessionFiles(rootDir) {
  const files = []
  async function walk(dir) {
    if (files.length >= MAX_SESSION_FILES) return
    let entries = []
    try {
      entries = await fs.readdir(dir, { withFileTypes: true })
    } catch {
      return
    }
    entries.sort((a, b) => b.name.localeCompare(a.name))
    for (const entry of entries) {
      if (files.length >= MAX_SESSION_FILES) return
      const itemPath = path.join(dir, entry.name)
      if (entry.isDirectory()) {
        await walk(itemPath)
      } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
        files.push(itemPath)
      }
    }
  }
  await walk(rootDir)
  return files
}

async function findSessionFile(files, conversation) {
  const id = String(conversation?.id || '').trim()
  if (!id) return ''
  const updatedAt = String(conversation?.updatedAt || '').trim()
  const dateMatch = updatedAt.match(/^(\d{4})-(\d{2})-(\d{2})/)
  if (dateMatch) {
    const dateDir = path.join(SESSIONS_DIR, dateMatch[1], dateMatch[2], dateMatch[3])
    try {
      const entries = await fs.readdir(dateDir)
      const match = entries.find((name) => name.endsWith('.jsonl') && name.includes(id))
      if (match) return path.join(dateDir, match)
    } catch {}
  }
  return files.find((file) => path.basename(file).includes(id)) || ''
}

async function readTextFileOrTail(filePath) {
  const stat = await fs.stat(filePath)
  if (stat.size <= MAX_SESSION_FILE_BYTES) return fs.readFile(filePath, 'utf8')
  const handle = await fs.open(filePath, 'r')
  try {
    const size = Math.min(stat.size, MAX_SESSION_FILE_BYTES)
    const buffer = Buffer.alloc(size)
    await handle.read(buffer, 0, size, Math.max(0, stat.size - size))
    const text = buffer.toString('utf8')
    return text.slice(text.indexOf('\n') + 1)
  } finally {
    await handle.close()
  }
}

async function readTextFileHead(filePath, maxBytes = 256 * 1024) {
  const stat = await fs.stat(filePath)
  const size = Math.min(stat.size, maxBytes)
  if (size <= 0) return ''
  const handle = await fs.open(filePath, 'r')
  try {
    const buffer = Buffer.alloc(size)
    await handle.read(buffer, 0, size, 0)
    return buffer.toString('utf8')
  } finally {
    await handle.close()
  }
}

async function readSessionWorkspaceMeta(filePath) {
  let text = ''
  try {
    text = await readTextFileHead(filePath)
  } catch {
    return null
  }
  for (const line of text.split(/\r?\n/)) {
    if (!line.trim()) continue
    try {
      const item = JSON.parse(line)
      const payload = item?.payload || {}
      if (item?.type === 'session_meta' && payload?.cwd) {
        return {
          cwd: String(payload.cwd || '').trim(),
          updatedAt: payload.timestamp || item.timestamp || null,
        }
      }
      const contentText = readContentText(payload?.content)
      const match = String(contentText || '').match(/<cwd>([\s\S]*?)<\/cwd>/i)
      if (match?.[1]) {
        return {
          cwd: match[1].trim(),
          updatedAt: item.timestamp || null,
        }
      }
    } catch {}
  }
  return null
}

function timestampMs(value) {
  const parsed = Date.parse(String(value || ''))
  return Number.isFinite(parsed) ? parsed : 0
}

function messageTimestampMs(message) {
  return timestampMs(message?.createdAt)
}

function itemTimestampMs(item) {
  return timestampMs(item?.timestamp || item?.createdAt || item?.created_at || item?.payload?.createdAt || item?.payload?.created_at)
}

function isFinalAssistantMessage(message) {
  if (message?.role !== 'assistant') return false
  const phase = String(message.phase || '').trim().toLowerCase()
  return phase ? FINAL_MESSAGE_PHASES.has(phase) : true
}

async function readSessionState(filePath) {
  let text = ''
  let updatedAt = null
  try {
    const stat = await fs.stat(filePath)
    updatedAt = stat.mtime?.toISOString?.() || null
    text = await readTextFileOrTail(filePath)
  } catch {
    return { messages: [], status: 'unknown', running: false, updatedAt }
  }

  const responseMessages = []
  const eventMessages = []
  let latestUserAt = 0
  let latestAssistantFinalAt = 0
  let latestActivityAt = 0
  let latestFailureAt = 0
  for (const line of text.split(/\r?\n/)) {
    if (!line.trim()) continue
    let item = null
    try {
      item = JSON.parse(line)
    } catch {
      continue
    }
    const responseMessage = extractResponseMessage(item)
    if (responseMessage) {
      responseMessages.push(responseMessage)
      const at = messageTimestampMs(responseMessage)
      latestActivityAt = Math.max(latestActivityAt, at)
      if (responseMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at)
      if (isFinalAssistantMessage(responseMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at)
      continue
    }
    const eventMessage = extractEventMessage(item)
    if (eventMessage) {
      eventMessages.push(eventMessage)
      const at = messageTimestampMs(eventMessage)
      latestActivityAt = Math.max(latestActivityAt, at)
      if (eventMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at)
      if (isFinalAssistantMessage(eventMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at)
    }
    const typeText = `${item?.type || ''} ${item?.payload?.type || ''}`.toLowerCase()
    if (/error|failed|interrupted|aborted/.test(typeText)) latestFailureAt = Math.max(latestFailureAt, itemTimestampMs(item))
  }

  const latestResponseAt = responseMessages.reduce((max, message) => Math.max(max, messageTimestampMs(message)), 0)
  const liveEventMessages = responseMessages.length
    ? eventMessages.filter((message) => messageTimestampMs(message) > latestResponseAt)
    : eventMessages
  const messages = dedupeMessages([...responseMessages, ...liveEventMessages]
    .sort((a, b) => messageTimestampMs(a) - messageTimestampMs(b)))
    .slice(-MAX_MESSAGES_PER_CONVERSATION)
  const failed = latestFailureAt > latestUserAt
  const rawRunning = latestUserAt > Math.max(latestAssistantFinalAt, latestFailureAt)
  const updatedAtMs = timestampMs(updatedAt)
  const staleRunning = rawRunning && Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs > STALE_RUNNING_SESSION_MS
  const running = rawRunning && !staleRunning
  const status = running ? 'running' : failed ? 'failed' : latestActivityAt ? 'completed' : 'unknown'
  return { messages, status, running, updatedAt }
}

async function readSessionMessages(filePath) {
  const state = await readSessionState(filePath)
  return state.messages
}

function extractResponseMessage(item) {
  const payload = item?.payload || {}
  if (item?.type !== 'response_item' || payload?.type !== 'message') return null
  const role = normalizeRole(payload.role)
  if (!role) return null
  const text = normalizeDisplayMessageText(role, readContentText(payload.content ?? payload.text ?? payload.message))
  if (!text || shouldSkipConversationText(text)) return null
  return {
    id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`),
    role,
    text,
    phase: String(payload.phase || item.phase || '').trim(),
    createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null,
  }
}

function extractEventMessage(item) {
  const payload = item?.payload || {}
  if (item?.type !== 'event_msg') return null
  const type = String(payload.type || '')
  const role = type === 'user_message' ? 'user' : type === 'agent_message' ? 'assistant' : ''
  if (!role) return null
  const text = normalizeDisplayMessageText(role, payload.message || payload.text || '')
  if (!text || shouldSkipConversationText(text)) return null
  return {
    id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`),
    role,
    text,
    phase: String(payload.phase || item.phase || '').trim(),
    createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null,
  }
}

function readContentText(content) {
  if (typeof content === 'string') return content
  if (!Array.isArray(content)) return ''
  return content
    .map((part) => {
      if (typeof part === 'string') return part
      if (typeof part?.text === 'string') return part.text
      if (typeof part?.content === 'string') return part.content
      return ''
    })
    .filter(Boolean)
    .join('\n')
}

function normalizeRole(role) {
  const value = String(role || '').trim().toLowerCase()
  if (value === 'user') return 'user'
  if (value === 'assistant') return 'assistant'
  return ''
}

function normalizeMessageText(value) {
  const text = String(value || '').replace(/\r\n/g, '\n').trim()
  if (!text) return ''
  return text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS).trimEnd()}\n\n[truncated]` : text
}

function normalizeDisplayMessageText(role, value) {
  const text = normalizeMessageText(value)
  if (role !== 'user') return text
  const clean = text.replace(/^user\s*\n/i, '').trim()
  if (!clean.includes('You are connected to NODE.DC AI Workspace through AI Workspace Bridge.')) return text
  const match = clean.match(/(?:^|\n)User message:\s*\n?([\s\S]*)$/i)
  return normalizeMessageText(match?.[1] || '')
}

function shouldSkipConversationText(text) {
  return /^\s*<(environment_context|permissions instructions|app-context|collaboration_mode|apps_instructions|skills_instructions|plugins_instructions|turn_aborted)\b/i.test(text)
}

function dedupeMessages(messages) {
  const result = []
  for (const message of messages) {
    const previous = result[result.length - 1]
    if (previous && previous.role === message.role && previous.text === message.text) continue
    result.push(message)
  }
  return result
}

function normalizeHistory(history) {
  if (!Array.isArray(history)) return []
  return history
    .slice(-40)
    .map((message) => ({
      role: normalizeRole(message?.role),
      text: normalizeMessageText(message?.text || message?.content || ''),
    }))
    .filter((message) => message.role && message.text)
}

function cleanSessionId(value) {
  const text = String(value || '').trim()
  if (!text || text.startsWith('ai-') || text.includes(':')) return ''
  return /^[A-Za-z0-9._-]{8,160}$/.test(text) ? text : ''
}

function buildResumeArgs(sessionId) {
  const clean = cleanSessionId(sessionId)
  if (!clean) return CODEX_ARGS
  if (CODEX_RESUME_ARGS.length) {
    return CODEX_RESUME_ARGS.map((item) => item.replace(/\{sessionId\}/g, clean))
  }
  const stdinIndex = CODEX_ARGS.lastIndexOf('-')
  const base = stdinIndex >= 0
    ? CODEX_ARGS.filter((_, index) => index !== stdinIndex)
    : [...CODEX_ARGS]
  if (base[0] !== 'exec') return CODEX_ARGS
  return [...base, 'resume', clean, '-']
}

function isPlainObject(value) {
  return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}

function isNdcAgentCorePayload(payload) {
  const context = isPlainObject(payload?.context) ? payload.context : {}
  return String(context.modeId || '').trim() === 'ndc-agent-core'
}

function insertBeforePromptStdin(args, extras) {
  const base = Array.isArray(args) ? [...args] : []
  const stdinIndex = base.lastIndexOf('-')
  if (stdinIndex < 0) return [...base, ...extras]
  return [...base.slice(0, stdinIndex), ...extras, ...base.slice(stdinIndex)]
}

function buildNdcAgentMcpContext(payload, cwd) {
  const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
  const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
  const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {}
  const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object'
    ? toolProfile.assistantActions
    : {}
  const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
  return {
    workflowId: String(context.workflowId || '').trim(),
    workflowTitle: String(context.workflowTitle || '').trim(),
    agentNodeId: String(context.agentNodeId || '').trim(),
    agentNodeTitle: String(context.agentNodeTitle || '').trim(),
    roleId: String(context.roleId || '').trim(),
    roleTitle: String(context.roleTitle || '').trim(),
    ndcAgentMcpApiBaseUrl: apiBaseUrl.replace(/\/+$/, ''),
    schemaVersion: 'v2.3.2',
    n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH,
    workspacePath: cwd,
    assistantActions: {
      actionIds: Array.isArray(assistantActions.actionIds)
        ? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean)
        : [],
    },
    assistantActionOwner: runProfile.owner && typeof runProfile.owner === 'object' ? runProfile.owner : {},
    assistantActionGatewayUrl: String(assistantActions.gatewayUrl || '').trim(),
    assistantActionGatewayToken: String(assistantActions.gatewayToken || '').trim(),
  }
}

function deriveNdcAgentMcpApiBaseUrl(context) {
  const explicit = cleanApiBaseUrl(context?.ndcAgentMcpApiBaseUrl || context?.engineApiBaseUrl || '')
  const hubDerived = deriveNdcAgentMcpApiBaseUrlFromHub()
  if (!explicit) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
  if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
  if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived
  if (isLoopbackUrl(explicit) && hubDerived) return hubDerived
  if (isPrivateNetworkUrl(explicit) && hubDerived) return hubDerived
  if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp`
  return explicit
}

function cleanApiBaseUrl(value) {
  const text = String(value || '').trim().replace(/\/+$/, '')
  return text || ''
}

function isLoopbackUrl(value) {
  try {
    const url = new URL(value)
    const host = url.hostname.toLowerCase()
    return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')
  } catch {
    return false
  }
}

function isPrivateNetworkUrl(value) {
  try {
    const url = new URL(value)
    const host = url.hostname.toLowerCase()
    if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true
    if (host.startsWith('192.168.')) return true
    if (host.startsWith('10.')) return true
    if (host.startsWith('169.254.')) return true
    const match = host.match(/^172\.(\d+)\./)
    return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
  } catch {
    return false
  }
}

function isHttpUrl(value) {
  try {
    const url = new URL(value)
    return url.protocol === 'http:' || url.protocol === 'https:'
  } catch {
    return false
  }
}

function deriveNdcAgentMcpApiBaseUrlFromHub() {
  return deriveNdcAgentMcpBaseFromHubUrl(hubState.url)
    || HUB_URLS.map(deriveNdcAgentMcpBaseFromHubUrl).find(Boolean)
    || ''
}

function deriveNdcAgentMcpBaseFromHubUrl(value) {
  try {
    const url = new URL(String(value || '').trim())
    const hostname = url.hostname.toLowerCase()
    if (!hostname) return ''
    if (url.protocol === 'wss:') url.protocol = 'https:'
    else if (url.protocol === 'ws:') url.protocol = 'http:'
    else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
    url.pathname = ''
    url.search = ''
    url.hash = ''
    const origin = url.toString().replace(/\/+$/, '')
    if (hostname === 'ai-hub.nodedc.ru') {
      const pairingCode = cleanHubPairingCode(PAIRING_CODE)
      return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : ''
    }
    return `${origin}/api/ndc-agent-mcp`
  } catch {
    return ''
  }
}

function cleanHubPairingCode(value) {
  return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
}

function isProtectedNodedcEngineUrl(value) {
  try {
    const url = new URL(String(value || '').trim())
    return url.hostname.toLowerCase() === 'engine.nodedc.ru'
  } catch {
    return false
  }
}

function tomlString(value) {
  return JSON.stringify(String(value || ''))
}

function tomlKey(value) {
  const key = String(value || '').trim()
  if (/^[A-Za-z0-9_-]+$/.test(key)) return key
  return JSON.stringify(key)
}

function cleanMcpServerName(value) {
  const text = String(value || '').trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
  return text.slice(0, 80)
}

function cleanRunDirectoryName(value) {
  const text = String(value || '').trim().replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '')
  return (text || `run-${Date.now()}`).slice(0, 120)
}

function positiveInteger(value, fallback, min = 1, max = 600) {
  const parsed = Number(value)
  if (!Number.isFinite(parsed)) return fallback
  return Math.max(min, Math.min(max, Math.trunc(parsed)))
}

function runProfileFromPayload(payload) {
  return isPlainObject(payload?.runProfile) ? payload.runProfile : {}
}

function runProfileMcpServers(payload) {
  const runProfile = runProfileFromPayload(payload)
  const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}
  const rawServers = Array.isArray(toolProfile.mcpServers) ? toolProfile.mcpServers : []
  const byName = new Map()
  for (const raw of rawServers) {
    const server = sanitizeRunMcpServer(raw)
    if (!server) continue
    byName.set(server.serverName, server)
  }
  return Array.from(byName.values())
}

function sanitizeRunMcpServer(raw) {
  if (!isPlainObject(raw) || raw.enabled === false) return null
  const serverName = cleanMcpServerName(raw.serverName || raw.server_name || raw.name)
  const url = String(raw.url || '').trim()
  if (!serverName || !isHttpUrl(url)) return null
  return {
    serverName,
    url,
    required: raw.required === true,
    startupTimeoutSec: positiveInteger(raw.startupTimeoutSec || raw.startup_timeout_sec, 20, 1, 120),
    toolTimeoutSec: positiveInteger(raw.toolTimeoutSec || raw.tool_timeout_sec, 60, 1, 600),
    httpHeaders: sanitizeRunMcpHeaders(raw.httpHeaders || raw.http_headers || raw.headers),
  }
}

function sanitizeRunMcpHeaders(value) {
  if (!isPlainObject(value)) return {}
  const out = {}
  for (const [key, item] of Object.entries(value)) {
    const headerName = String(key || '').trim()
    const headerValue = String(item ?? '').trim()
    if (!headerName || !headerValue) continue
    if (/[\r\n\0]/.test(headerName) || /[\r\n\0]/.test(headerValue)) continue
    out[headerName] = headerValue
  }
  return out
}

function runtimeCodexConfig() {
  return [
    'approval_policy = "never"',
    'sandbox_mode = "danger-full-access"',
  ].join('\n')
}

function dynamicMcpServerConfig(server) {
  const lines = [
    `[mcp_servers.${tomlKey(server.serverName)}]`,
    `url = ${tomlString(server.url)}`,
    'enabled = true',
    `required = ${server.required ? 'true' : 'false'}`,
    `startup_timeout_sec = ${positiveInteger(server.startupTimeoutSec, 20, 1, 120)}`,
    `tool_timeout_sec = ${positiveInteger(server.toolTimeoutSec, 60, 1, 600)}`,
  ]
  const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {}
  const headerEntries = Object.entries(headers)
  if (headerEntries.length) {
    lines.push('', `[mcp_servers.${tomlKey(server.serverName)}.http_headers]`)
    for (const [key, value] of headerEntries) {
      lines.push(`${tomlKey(key)} = ${tomlString(value)}`)
    }
  }
  return lines.join('\n')
}

function dynamicMcpConfig(servers) {
  return (Array.isArray(servers) ? servers : [])
    .map(dynamicMcpServerConfig)
    .filter(Boolean)
    .join('\n\n')
}

function ndcAgentMcpServerConfig(mcpContext, cwd) {
  const ndcConfig = [
    '[mcp_servers.ndc_agent_core]',
    `command = ${tomlString('node')}`,
    `args = [${tomlString(NDC_AGENT_MCP_SERVER)}]`,
    'startup_timeout_sec = 20',
    'tool_timeout_sec = 120',
  ].join('\n')
  return `${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}`
}

function dynamicCodexHomePath(payload, needsNdcAgentCore) {
  const runProfile = runProfileFromPayload(payload)
  const runId = cleanRunDirectoryName(runProfile.runId || payload?.requestId || payload?.threadId || '')
  return path.join(RUN_CODEX_HOME_ROOT, needsNdcAgentCore ? `ndc-${runId}` : runId)
}

function stripTomlSection(raw, sectionName) {
  const section = String(sectionName || '').trim()
  if (!section) return String(raw || '')
  const lines = String(raw || '').split(/\r?\n/)
  const result = []
  let skipping = false
  for (const line of lines) {
    const header = normalizedTomlHeader(line)
    if (header) {
      skipping = header === `[${section}]` || header.startsWith(`[${section}.`)
    }
    if (!skipping) result.push(line)
  }
  return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
}

function stripTopLevelTomlKeys(raw, keyNames) {
  const keys = new Set((Array.isArray(keyNames) ? keyNames : []).map((key) => String(key || '').trim()).filter(Boolean))
  if (!keys.size) return String(raw || '')
  const lines = String(raw || '').split(/\r?\n/)
  const result = []
  let inTable = false
  for (const line of lines) {
    if (normalizedTomlHeader(line)) {
      inTable = true
      result.push(line)
      continue
    }
    if (!inTable) {
      const match = stripTomlComment(line).match(/^\s*([A-Za-z0-9_-]+)\s*=/)
      if (match && keys.has(match[1])) continue
    }
    result.push(line)
  }
  return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
}

function extractTomlSection(raw, sectionName) {
  const section = String(sectionName || '').trim()
  if (!section) return ''
  const headerKey = `[${section}]`
  const lines = String(raw || '').split(/\r?\n/)
  const result = []
  let collecting = false
  for (const line of lines) {
    const header = normalizedTomlHeader(line)
    if (header) {
      if (header === headerKey) {
        collecting = true
        result.push(line)
        continue
      }
      if (collecting) break
    }
    if (collecting) result.push(line)
  }
  return result.join('\n').trim()
}

function normalizeMcpServerSection(section, serverName) {
  const lines = String(section || '').split(/\r?\n/)
  const result = []
  let hasEnabled = false
  let hasRequired = false
  for (const line of lines) {
    const header = normalizedTomlHeader(line)
    if (header) {
      result.push(`[mcp_servers.${serverName}]`)
      continue
    }
    const activeLine = stripTomlComment(line)
    if (/^\s*transport\s*=/.test(activeLine)) continue
    if (/^\s*required\s*=/.test(activeLine)) {
      result.push('required = false')
      hasRequired = true
      continue
    }
    if (/^\s*enabled\s*=/.test(activeLine)) {
      result.push('enabled = true')
      hasEnabled = true
      continue
    }
    result.push(line)
  }
  if (!hasEnabled) result.push('enabled = true')
  if (!hasRequired) result.push('required = false')
  return result.join('\n').trim()
}

function normalizeMcpHeadersSection(section, serverName) {
  if (!section) return ''
  const lines = String(section || '').split(/\r?\n/)
  const result = []
  for (const line of lines) {
    const header = normalizedTomlHeader(line)
    if (header) {
      result.push(`[mcp_servers.${serverName}.http_headers]`)
      continue
    }
    result.push(line)
  }
  return result.join('\n').trim()
}

function extractOptionalOpsMcpConfig(raw) {
  const serverNames = ['nodedc-ops-agent', 'nodedc_tasker']
  const configs = []
  for (const serverName of serverNames) {
    const baseSection = extractTomlSection(raw, `mcp_servers.${serverName}`)
    if (!baseSection) continue
    const headersSection = extractTomlSection(raw, `mcp_servers.${serverName}.http_headers`)
      || extractTomlSection(raw, `mcp_servers.${serverName}.headers`)
    const normalized = [
      normalizeMcpServerSection(baseSection, serverName),
      normalizeMcpHeadersSection(headersSection, serverName),
    ].filter(Boolean).join('\n\n').trim()
    if (normalized) configs.push(normalized)
  }
  return configs.join('\n\n').trim()
}

async function readOptionalOpsMcpConfig() {
  try {
    const raw = await fs.readFile(path.join(CODEX_HOME, 'config.toml'), 'utf8')
    return extractOptionalOpsMcpConfig(raw)
  } catch {
    return ''
  }
}

async function copyIfExists(from, to) {
  try {
    await fs.copyFile(from, to)
    return true
  } catch (error) {
    if (error?.code === 'ENOENT') return false
    throw error
  }
}

function ndcAgentMcpEnvConfig(mcpContext, cwd) {
  return [
    '[mcp_servers.ndc_agent_core.env]',
    `NDC_AGENT_MCP_ROOT = ${tomlString(CODEX_CWD)}`,
    `NDC_AGENT_MCP_CONTEXT = ${tomlString(JSON.stringify(mcpContext || {}))}`,
    `NDC_AGENT_MCP_API_BASE_URL = ${tomlString(mcpContext?.ndcAgentMcpApiBaseUrl || '')}`,
    `AI_BRIDGE_HUB_URL = ${tomlString(hubState.url || HUB_URL)}`,
    `AI_BRIDGE_HUB_URLS = ${tomlString(HUB_URLS.join(','))}`,
    `AI_BRIDGE_PAIRING_CODE = ${tomlString(PAIRING_CODE)}`,
    `AI_BRIDGE_CODEX_CWD = ${tomlString(cwd || CODEX_CWD)}`,
  ].join('\n')
}

async function prepareRunCodexHome({ payload = {}, cwd = CODEX_CWD, mcpContext = null, dynamicMcpServers = [] } = {}) {
  const needsNdcAgentCore = isPlainObject(mcpContext)
  const hasDynamicMcp = Array.isArray(dynamicMcpServers) && dynamicMcpServers.length > 0
  const codexHome = hasDynamicMcp
    ? dynamicCodexHomePath(payload, needsNdcAgentCore)
    : NDC_AGENT_CODEX_HOME
  await fs.mkdir(codexHome, { recursive: true })
  await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(codexHome, 'auth.json'))
  const legacyOpsMcpConfig = ''
  const config = [
    runtimeCodexConfig(),
    needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '',
    hasDynamicMcp ? dynamicMcpConfig(dynamicMcpServers) : legacyOpsMcpConfig,
  ].filter(Boolean).join('\n\n')
  await fs.writeFile(path.join(codexHome, 'config.toml'), `${config}\n`, 'utf8')
  return codexHome
}

async function codexInvocationForPayload(baseArgs, payload, cwd) {
  const dynamicMcpServers = runProfileMcpServers(payload)
  const needsNdcAgentCore = isNdcAgentCorePayload(payload)
  if (!needsNdcAgentCore && !dynamicMcpServers.length) return { args: baseArgs, env: {}, dynamicMcpServerNames: [] }
  const mcpContext = needsNdcAgentCore ? buildNdcAgentMcpContext(payload, cwd) : null
  const codexHome = await prepareRunCodexHome({ payload, cwd, mcpContext, dynamicMcpServers })
  return {
    args: baseArgs,
    env: {
      CODEX_HOME: codexHome,
      ...(needsNdcAgentCore ? {
        NDC_AGENT_MCP_ROOT: CODEX_CWD,
        NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext),
        NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl,
        NDC_AGENT_MCP_FETCH_TIMEOUT_MS: process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || '30000',
        AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS: process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || '45000',
        AI_BRIDGE_PAIRING_CODE: PAIRING_CODE,
      } : {}),
    },
    dynamicMcpServerNames: dynamicMcpServers.map((server) => server.serverName),
    codexHome,
  }
}

function isResumeRecoverableError(error) {
  const text = String(error?.message || error || '')
  return /unknown command|unrecognized|unexpected argument|invalid subcommand|usage:|failed to refresh available models|timeout waiting for child process to exit|codex_timeout|session.*not.*found|no such session|could not find.*session|session.*does not exist|no rollout found|thread\/resume.*failed|error resuming thread/i.test(text)
}

function usesJsonOutput(args) {
  return Array.isArray(args) && args.includes('--json')
}

const CODEX_ASSISTANT_TEXT_MAX = 250000

function truncateText(value, max = 1400) {
  const text = String(value || '').trim()
  return text.length > max ? `${text.slice(0, max).trim()}...` : text
}

function markdownText(value, max = 12000) {
  return truncateText(String(value || '').replace(/\r\n/g, '\n'), max)
}

function sanitizeCodexErrorText(value, max = 1400) {
  const text = String(value || '').replace(/\r\n/g, '\n').trim()
  if (!text) return ''
  const statusMatch = text.match(/\b(?:HTTP error:\s*)?((?:401|403|429|5\d\d)\s+[A-Za-z][A-Za-z\s-]*)\b/i)
  const urlMatch = text.match(/\burl:\s*((?:https?|wss):\/\/[^\s,]+)/i)
    || text.match(/\b((?:https?|wss):\/\/chatgpt\.com\/[^\s,]+)/i)
  const rayMatch = text.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i)
    || text.match(/\bRay ID:?\s*([A-Za-z0-9-]+)/i)
  if (/chatgpt\.com\/backend-api\/codex|Unable to load site|cf-ray|Cloudflare|403 Forbidden/i.test(text)) {
    return [
      `Codex backend rejected request${statusMatch ? `: ${statusMatch[1].trim()}` : ''}.`,
      urlMatch ? `url: ${urlMatch[1].replace(/[)>]+$/, '')}` : '',
      rayMatch ? `cf-ray: ${rayMatch[1]}` : '',
    ].filter(Boolean).join(' ')
  }
  return truncateText(text, max)
}

function eventText(event) {
  return [
    event?.message,
    event?.text,
    event?.command ? `$ ${event.command}` : '',
  ].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}

function renderMirrorSignal(label, value) {
  const text = markdownText(value, 1800)
  return text ? `- ${label}: ${text.replace(/\n/g, '\n  ')}` : ''
}

function renderActiveMirrorMarkdown(state) {
  const activity = state.events.length
    ? state.events.map((event) => {
      const text = markdownText(event.text, 2200)
      const inline = text.includes('\n') ? `\n\n${text}\n` : ` ${text}`
      return `- ${event.at} ${event.kind}:${inline}`
    }).join('\n')
    : '- waiting for Codex events'
  const signal = [
    renderMirrorSignal('Reasoning', state.currentReasoning),
    renderMirrorSignal('Command', state.currentCommand),
    renderMirrorSignal('Files', state.currentFiles),
    renderMirrorSignal('Tool', state.currentTool),
    renderMirrorSignal('Todo', state.currentTodo),
  ].filter(Boolean).join('\n') || '- waiting for first Codex signal'
  return [
    '# NDC AI Workspace Active Run',
    '',
    `Status: ${state.status}`,
    `Machine: ${state.machineName}`,
    `Workspace: ${state.cwd}`,
    `Mode: ${state.modeTitle || state.modeId || 'unknown'}`,
    `Thread: ${state.threadTitle || state.threadId || 'unknown'}`,
    `Request: ${state.requestId}`,
    `Started: ${state.startedAt}`,
    `Updated: ${state.updatedAt}`,
    state.finishedAt ? `Finished: ${state.finishedAt}` : '',
    '',
    '## Active Mirror',
    '',
    `Current file: ${state.currentFile}`,
    `Events file: ${state.eventsFile}`,
    '',
    '## User Message',
    '',
    markdownText(state.userMessage || ''),
    '',
    '## Current Signal',
    '',
    signal,
    '',
    '## Live Activity',
    '',
    activity,
    '',
    '## Last Assistant Output',
    '',
    markdownText(state.assistantText || 'No assistant output yet.'),
    '',
  ].filter((line) => line !== '').join('\n')
}

function openActiveMirrorInIde(filePath) {
  if (!ACTIVE_MIRROR_OPEN_IDE || !ACTIVE_MIRROR_IDE_BIN || !filePath) return
  try {
    const child = spawn(ACTIVE_MIRROR_IDE_BIN, ['-r', filePath], {
      detached: true,
      shell: process.platform === 'win32',
      stdio: 'ignore',
      windowsHide: true,
    })
    child.unref()
  } catch {}
}

function activeMirrorPathsForCwd(cwd) {
  const workspace = resolveWorkspacePath(cwd || CODEX_CWD)
  const mirrorDir = workspace === CODEX_CWD
    ? ACTIVE_MIRROR_DIR
    : path.join(workspace, '.nodedc', 'ai-workspace')
  return {
    mirrorDir,
    currentFile: path.join(mirrorDir, 'current.md'),
    eventsFile: path.join(mirrorDir, 'events.jsonl'),
  }
}

async function hasGitMarker(dir) {
  try {
    await fs.stat(path.join(dir, '.git'))
    return true
  } catch {
    return false
  }
}

async function addWorkspace(map, workspacePath, source, patch = {}) {
  const cleanPath = String(workspacePath || '').trim()
  if (!cleanPath) return
  let stat = null
  try {
    stat = await fs.stat(cleanPath)
  } catch {
    return
  }
  if (!stat.isDirectory()) return
  const id = workspaceId(cleanPath)
  const previous = map.get(id) || {}
  map.set(id, {
    id,
    path: cleanPath,
    title: patch.title || previous.title || workspaceTitle(cleanPath),
    kind: patch.kind || previous.kind || (await hasGitMarker(cleanPath) ? 'git' : 'folder'),
    source: Array.from(new Set([...(previous.source ? String(previous.source).split(', ') : []), source].filter(Boolean))).join(', '),
    lastUsedAt: patch.lastUsedAt || previous.lastUsedAt || null,
    sessionCount: Number(previous.sessionCount || 0) + Number(patch.sessionCount || 0),
  })
}

async function discoverWorkspaces() {
  const map = new Map()
  await addWorkspace(map, CODEX_CWD, 'configured', { title: `${workspaceTitle(CODEX_CWD)} (default)` })
  const conversations = await readConversations()
  for (const conversation of conversations) {
    if (!conversation.workspacePath) continue
    await addWorkspace(map, conversation.workspacePath, 'codex-session', {
      lastUsedAt: conversation.workspaceUpdatedAt || conversation.updatedAt || null,
      sessionCount: 1,
    })
  }
  const workspaces = Array.from(map.values())
    .sort((a, b) => {
      if (a.path === CODEX_CWD) return -1
      if (b.path === CODEX_CWD) return 1
      const byDate = String(b.lastUsedAt || '').localeCompare(String(a.lastUsedAt || ''))
      if (byDate) return byDate
      return String(a.title || '').localeCompare(String(b.title || ''))
    })
    .slice(0, MAX_WORKSPACES)
  return {
    ok: true,
    cwd: CODEX_CWD,
    workspaces,
  }
}

function createActiveMirror(command, payload = {}, meta = {}) {
  const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
  if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null
  const cwd = String(meta.cwd || CODEX_CWD)
  const displayMessage = String(payload.displayMessage || payload.publicUserMessage || payload.message || '')
  const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd)
  const startedAt = new Date().toISOString()
  const state = {
    requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`),
    threadId: String(payload.threadId || ''),
    threadTitle: String(payload.threadTitle || ''),
    userMessage: displayMessage,
    modeId: String(context.modeId || ''),
    modeTitle: String(context.modeTitle || ''),
    machineName: MACHINE_NAME,
    cwd,
    status: 'running',
    startedAt,
    updatedAt: startedAt,
    finishedAt: '',
    assistantText: '',
    currentFile,
    eventsFile,
    currentReasoning: '',
    currentCommand: '',
    currentFiles: '',
    currentTool: '',
    currentTodo: '',
    events: [],
    ideOpened: false,
  }
  let chain = Promise.resolve()

  const write = (row) => {
    chain = chain.then(async () => {
      state.updatedAt = new Date().toISOString()
      await fs.mkdir(mirrorDir, { recursive: true })
      if (row) {
        await fs.appendFile(eventsFile, `${JSON.stringify({
          requestId: state.requestId,
          threadId: state.threadId,
          threadTitle: state.threadTitle,
          machineName: state.machineName,
          cwd: state.cwd,
          ...row,
        })}\n`, 'utf8')
      }
      await fs.writeFile(currentFile, renderActiveMirrorMarkdown(state), 'utf8')
      if (!state.ideOpened) {
        state.ideOpened = true
        openActiveMirrorInIde(currentFile)
      }
    }).catch(() => {})
    return chain
  }

  const record = (event = {}) => {
    const kind = String(event.kind || event.type || 'event')
    const text = eventText(event)
    const row = {
      at: new Date().toISOString(),
      kind,
      text: markdownText(text, 6000),
    }
    if (event.currentFile) row.currentFile = String(event.currentFile)
    if (event.eventsFile) row.eventsFile = String(event.eventsFile)
    if (kind === 'codex_reasoning') state.currentReasoning = markdownText(event.text || text, 6000)
    if (kind === 'codex_command' || kind === 'start') state.currentCommand = markdownText(event.command || text, 3000)
    if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000)
    if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000)
    if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000)
    if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, CODEX_ASSISTANT_TEXT_MAX)
    if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed'
    if (kind === 'timeout') state.status = 'timeout'
    if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed'
    state.events.push(row)
    state.events = state.events.slice(-ACTIVE_MIRROR_MAX_EVENTS)
    write(row)
  }

  return {
    currentFile,
    eventsFile,
    record,
    async complete(status, assistantText = '') {
      state.status = status
      state.finishedAt = new Date().toISOString()
      if (assistantText) state.assistantText = markdownText(assistantText, CODEX_ASSISTANT_TEXT_MAX)
      await write({
        at: state.finishedAt,
        kind: `mirror_${status}`,
        text: status,
      })
    },
  }
}

function textFromUnknown(value) {
  if (typeof value === 'string') return value
  if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join('\n')
  if (value && typeof value === 'object') {
    return [
      value.text,
      value.summary,
      value.content,
      value.message,
      value.delta,
    ].map(textFromUnknown).filter(Boolean).join('\n')
  }
  return ''
}

function reasoningTextFromItem(item) {
  return textFromUnknown([
    item?.summary,
    item?.summary_text,
    item?.text,
    item?.content,
  ])
}

function reasoningSummaryEventKey(event) {
  const itemId = String(event?.item_id || event?.item?.id || event?.response_id || 'reasoning').trim() || 'reasoning'
  const summaryIndex = String(event?.summary_index ?? event?.content_index ?? event?.output_index ?? 0)
  return `${itemId}:${summaryIndex}`
}

function reasoningSummaryTextFromEvent(event) {
  return textFromUnknown([
    event?.text,
    event?.delta,
    event?.part,
    event?.summary,
    event?.summary_text,
  ])
}

function shouldEmitReasoningSummaryDelta(record, pending) {
  const text = String(pending || '').trim()
  if (!text) return false
  if (/[.!?。！？]\s*$/.test(text)) return true
  if (text.length >= REASONING_SUMMARY_DELTA_MIN_CHARS) return true
  return nowMs() - Number(record.lastEmittedAt || 0) >= REASONING_SUMMARY_DELTA_MAX_WAIT_MS && text.length >= 32
}

function updateReasoningSummaryState(state, event, mode) {
  if (!state) return null
  const key = reasoningSummaryEventKey(event)
  const record = state.get(key) || { text: '', emittedLength: 0, lastEmittedAt: 0 }
  if (mode === 'delta') {
    const delta = textFromUnknown(event?.delta)
    if (!delta) return null
    record.text += delta
    const pending = record.text.slice(record.emittedLength).trim()
    if (!shouldEmitReasoningSummaryDelta(record, pending)) {
      state.set(key, record)
      return null
    }
    record.emittedLength = record.text.length
    record.lastEmittedAt = nowMs()
    state.set(key, record)
    return pending
  }

  const text = reasoningSummaryTextFromEvent(event)
  if (text) record.text = text
  const pending = record.text.slice(record.emittedLength).trim() || (!record.emittedLength ? record.text.trim() : '')
  record.emittedLength = record.text.length
  record.lastEmittedAt = nowMs()
  state.set(key, record)
  return pending
}

function createCodexJsonEventMapper() {
  const reasoningSummaryState = new Map()
  return (event) => codexJsonEventToBridgeEvent(event, reasoningSummaryState)
}

function summarizeFileChanges(changes) {
  const list = Array.isArray(changes) ? changes : []
  if (!list.length) return 'File changes reported.'
  return list
    .slice(0, 12)
    .map((change) => `${String(change.kind || 'update')}: ${String(change.path || '')}`.trim())
    .join('\n')
}

function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) {
  const type = String(event?.type || '')
  const item = event?.item && typeof event.item === 'object' ? event.item : null
  const itemType = String(item?.type || '')
  if (type === 'response.reasoning_summary_text.delta') {
    const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'delta'))
    return text ? { kind: 'codex_reasoning', message: text } : null
  }
  if (type === 'response.reasoning_summary_text.done') {
    const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'done'))
    return text ? { kind: 'codex_reasoning', message: text } : null
  }
  if (type === 'response.reasoning_summary_part.done' || type === 'response.reasoning_summary_part.added') {
    const text = truncateText(reasoningSummaryTextFromEvent(event))
    return text ? { kind: 'codex_reasoning', message: text } : null
  }
  if (type === 'response.reasoning_text.delta' || type === 'response.reasoning_text.done') return null
  if (type === 'thread.started') {
    const sessionId = String(event.thread_id || '').trim()
    return { kind: 'codex_thread', sessionId, message: `Thread started: ${sessionId}`.trim() }
  }
  if (type === 'turn.started') return { kind: 'codex_turn', message: 'Turn started.' }
  if (type === 'turn.completed') {
    const usage = event.usage || {}
    const tokens = Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0)
    return { kind: 'codex_turn_completed', message: tokens ? `Turn completed. Tokens: ${tokens}.` : 'Turn completed.' }
  }
  if (type === 'turn.failed') return { kind: 'error', message: event?.error?.message || 'Turn failed.' }
  if (type === 'error') return { kind: 'error', message: event.message || 'Codex stream error.' }
  if (!item) return null

  const prefix = type === 'item.started' ? 'Started' : type === 'item.updated' ? 'Updated' : 'Completed'
  if (itemType === 'reasoning') {
    const text = truncateText(reasoningTextFromItem(item))
    return text ? { kind: 'codex_reasoning', message: text } : null
  }
  if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: markdownText(item.text || '', CODEX_ASSISTANT_TEXT_MAX) }
  if (itemType === 'command_execution') {
    const status = item.status ? ` - ${item.status}` : ''
    return {
      kind: 'codex_command',
      command: item.command || '',
      message: `${prefix} command${status}: ${item.command || ''}`.trim(),
      text: truncateText(item.aggregated_output || ''),
    }
  }
  if (itemType === 'file_change') {
    return {
      kind: 'codex_files',
      message: `${prefix} file change${item.status ? ` - ${item.status}` : ''}.`,
      text: summarizeFileChanges(item.changes),
    }
  }
  if (itemType === 'mcp_tool_call') {
    return {
      kind: 'codex_tool',
      message: `${prefix} MCP ${item.server || ''}.${item.tool || ''}${item.status ? ` - ${item.status}` : ''}`.trim(),
      text: truncateText(item.error?.message || ''),
    }
  }
  if (itemType === 'todo_list') {
    const todos = Array.isArray(item.items)
      ? item.items.map((todo) => `${todo.completed ? '[x]' : '[ ]'} ${todo.text || ''}`).join('\n')
      : ''
    return { kind: 'codex_todo', message: `${prefix} todo list.`, text: truncateText(todos) }
  }
  if (itemType === 'web_search') return { kind: 'codex_tool', message: `${prefix} web search: ${item.query || ''}`.trim() }
  if (itemType === 'error') return { kind: 'error', message: item.message || 'Codex item error.' }
  return { kind: 'codex_event', message: `${prefix} ${itemType || type}.` }
}

function finalMessageFromCodexEvent(event) {
  const item = event?.item && typeof event.item === 'object' ? event.item : null
  if (!item || item.type !== 'agent_message') return ''
  return String(item.text || '').trim()
}

async function runCodexForPayload(prompt, payload, onEvent = () => {}, cwd = CODEX_CWD, meta = {}) {
  const baseArgs = payload?.resume ? buildResumeArgs(payload?.threadId) : CODEX_ARGS
  const invocation = await codexInvocationForPayload(baseArgs, payload, cwd)
  if (isNdcAgentCorePayload(payload)) {
    try {
      const context = buildNdcAgentMcpContext(payload, cwd)
      onEvent({
        kind: 'ndc_agent_mcp_context',
        message: `NDC Agent MCP API base: ${context.ndcAgentMcpApiBaseUrl}`,
      })
    } catch {}
  }
  if (Array.isArray(invocation.dynamicMcpServerNames) && invocation.dynamicMcpServerNames.length) {
    onEvent({
      kind: 'run_profile',
      message: `Dynamic run profile MCP servers: ${invocation.dynamicMcpServerNames.join(', ')}`,
    })
  }
  const control = {
    requestId: meta?.requestId,
    threadId: payload?.threadId,
  }
  if (baseArgs === CODEX_ARGS) {
    return runCodex(prompt, invocation.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, invocation.env)
  }
  try {
    return await runCodex(prompt, invocation.args, onEvent, RESUME_TIMEOUT_MS, cwd, control, invocation.env)
  } catch (error) {
    if (!isResumeRecoverableError(error)) throw error
    onEvent({ kind: 'fallback', message: 'Codex resume failed; falling back to a new exec run.' })
    const fallback = await codexInvocationForPayload(CODEX_ARGS, payload, cwd)
    return runCodex(prompt, fallback.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, fallback.env)
  }
}

function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MESSAGE_TIMEOUT_MS, cwd = CODEX_CWD, control = {}, envOverrides = {}) {
  return new Promise((resolve, reject) => {
    const startedAt = nowMs()
    let firstOutputSeen = false
    const jsonOutput = usesJsonOutput(args)
    let jsonBuffer = ''
    let finalJsonMessage = ''
    let plainStdout = ''
    const mapCodexJsonEvent = createCodexJsonEventMapper()
    onEvent({
      kind: 'start',
      command: `${CODEX_BIN} ${args.join(' ')}`,
      cwd,
      message: 'Codex process started.',
    })
    const child = spawn(CODEX_BIN, args, {
      cwd,
      env: { ...process.env, ...(envOverrides || {}) },
      shell: process.platform === 'win32',
      stdio: ['pipe', 'pipe', 'pipe'],
      windowsHide: true,
    })
    const activeRun = registerActiveCodexRun(control, child, onEvent)
    let stdout = ''
    let stderr = ''
    let killed = false
    const timeout = setTimeout(() => {
      killed = true
      onEvent({ kind: 'timeout', message: 'Codex process timed out.' })
      terminateProcessTree(child, 'SIGTERM')
    }, timeoutMs)

    child.stdout.on('data', (chunk) => {
      const text = String(chunk)
      if (!firstOutputSeen) {
        firstOutputSeen = true
        onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` })
      }
      stdout += text
      if (jsonOutput) {
        jsonBuffer += text
        const lines = jsonBuffer.split(/\r?\n/)
        jsonBuffer = lines.pop() || ''
        lines.forEach((line) => {
          const trimmed = line.trim()
          if (!trimmed) return
          try {
            const event = JSON.parse(trimmed)
            const finalMessage = finalMessageFromCodexEvent(event)
            if (finalMessage) finalJsonMessage = finalMessage
            const bridgeEvent = mapCodexJsonEvent(event)
            if (bridgeEvent) onEvent(bridgeEvent)
          } catch {
            plainStdout += `${line}\n`
            onEvent({ kind: 'stdout', stream: 'stdout', text: line })
          }
        })
      } else {
        plainStdout += text
        onEvent({ kind: 'stdout', stream: 'stdout', text })
      }
      if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) terminateProcessTree(child, 'SIGTERM')
    })
    child.stderr.on('data', (chunk) => {
      const text = String(chunk)
      if (!firstOutputSeen) {
        firstOutputSeen = true
        onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` })
      }
      stderr += text
      onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) })
      if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) terminateProcessTree(child, 'SIGTERM')
    })
    child.on('error', (error) => {
      clearTimeout(timeout)
      activeRun?.unregister()
      onEvent({ kind: 'error', message: String(error?.message || error || 'codex_error') })
      reject(error)
    })
    child.on('close', (code) => {
      clearTimeout(timeout)
      activeRun?.unregister()
      if (activeRun?.stopRequested) {
        onEvent({ kind: 'stopped', message: 'Codex process stopped.' })
        return resolve('')
      }
      const stderrMessage = sanitizeCodexErrorText(stderr.trim())
      if (killed) return reject(new Error(`codex_timeout${stderrMessage ? `: ${stderrMessage.slice(0, 1000)}` : ''}`))
      if (code !== 0) {
        onEvent({ kind: 'error', message: stderrMessage || `codex_exit_${code}` })
        return reject(new Error(stderrMessage || `codex_exit_${code}`))
      }
      onEvent({ kind: 'done', message: `Codex process completed in ${elapsedLabel(startedAt)}.` })
      resolve(finalJsonMessage || plainStdout.trim() || stdout.trim() || stderr.trim())
    })
    child.stdin.end(prompt)
  })
}

function stripTomlComment(line) {
  const text = String(line || '')
  let inSingle = false
  let inDouble = false
  let escaped = false
  for (let i = 0; i < text.length; i += 1) {
    const char = text[i]
    if (escaped) {
      escaped = false
      continue
    }
    if (inDouble && char === '\\') {
      escaped = true
      continue
    }
    if (!inDouble && char === "'") {
      inSingle = !inSingle
      continue
    }
    if (!inSingle && char === '"') {
      inDouble = !inDouble
      continue
    }
    if (!inSingle && !inDouble && char === '#') {
      return text.slice(0, i)
    }
  }
  return text
}

function normalizedTomlKey(line) {
  return stripTomlComment(line).replace(/\s+/g, '').replace(/["']/g, '')
}

function normalizedTomlHeader(line) {
  const text = stripTomlComment(line).trim()
  if (!text.startsWith('[') || !text.endsWith(']')) return ''
  return normalizedTomlKey(text)
}

function hasOpsAgentInlineTransport(line) {
  const text = normalizedTomlKey(line)
  return /^nodedc-ops-agent=\{.*(?:^|[,}])?transport=/.test(text)
}

async function inspectCodexRuntime() {
  const configPath = path.join(CODEX_HOME, 'config.toml')
  const authPath = path.join(CODEX_HOME, 'auth.json')
  try {
    await fs.access(authPath)
  } catch {
    return {
      ok: false,
      error: 'codex_auth_missing',
      configPath,
      authPath,
    }
  }
  try {
    const raw = await fs.readFile(configPath, 'utf8')
    let inMcpServers = false
    let inOpsAgent = false
    for (const line of raw.split(/\r?\n/)) {
      const header = normalizedTomlHeader(line)
      if (header) {
        if (header === '[mcp_servers.nodedc-ops-agent.headers]') {
          return {
            ok: false,
            error: 'invalid_codex_mcp_headers:nodedc-ops-agent',
            configPath,
            authPath,
          }
        }
        inMcpServers = header === '[mcp_servers]'
        inOpsAgent = header === '[mcp_servers.nodedc-ops-agent]'
        continue
      }
      const activeLine = stripTomlComment(line)
      if (
        (inOpsAgent && /^\s*transport\s*=/.test(activeLine)) ||
        (inMcpServers && hasOpsAgentInlineTransport(activeLine))
      ) {
        return {
          ok: false,
          error: 'invalid_codex_mcp_transport:nodedc-ops-agent',
          configPath,
          authPath,
        }
      }
    }
  } catch (error) {
    if (error?.code !== 'ENOENT') {
      return {
        ok: false,
        error: `codex_config_read_failed:${String(error?.message || error)}`,
        configPath,
        authPath,
      }
    }
  }
  return { ok: true, error: '', configPath, authPath }
}

async function handleBridgeCommand(command, payload = {}, onEvent = () => {}, meta = {}) {
  if (command === 'health') {
    const runtime = await inspectCodexRuntime()
    return {
      ok: runtime.ok,
      error: runtime.error,
      service: 'ai-workspace-bridge',
      host: os.hostname(),
      machineName: MACHINE_NAME,
      runtimeStatus: runtime.ok ? 'ready' : 'error',
      runtimeError: runtime.error,
      runtimeCheckedAt: new Date().toISOString(),
      codexHome: CODEX_HOME,
      codexAuthPath: runtime.authPath,
      codexBin: CODEX_BIN,
      codexArgs: CODEX_ARGS,
      dynamicRunProfile: {
        enabled: true,
        codexHomeRoot: RUN_CODEX_HOME_ROOT,
      },
      cwd: CODEX_CWD,
      hubMode: hubState.mode,
      hubConnected: hubState.connected,
      hub: { ...hubState },
      activeMirror: {
        enabled: ACTIVE_MIRROR_ENABLED,
        currentFile: ACTIVE_MIRROR_CURRENT_FILE,
        eventsFile: ACTIVE_MIRROR_EVENTS_FILE,
      },
    }
  }

  if (command === 'conversations') {
    const conversations = await readConversations()
    return { ok: true, conversations }
  }

  if (command === 'workspaces') {
    return discoverWorkspaces()
  }

  if (command === 'stop') {
    return stopActiveCodexRun(payload)
  }

  if (command === 'message') {
    const message = String(payload?.message || '').trim()
    if (!message) {
      const error = new Error('message_empty')
      error.status = 400
      throw error
    }
    const runtime = await inspectCodexRuntime()
    if (!runtime.ok) {
      const error = new Error(runtime.error || 'codex_runtime_not_ready')
      error.status = 503
      throw error
    }
    const cwd = await resolveExistingDirectory(payload?.workspacePath || payload?.context?.workspacePath || CODEX_CWD)
    const messagePayload = {
      ...payload,
      workspacePath: cwd,
      context: {
        ...(payload?.context && typeof payload.context === 'object' ? payload.context : {}),
        workspacePath: cwd,
        workspaceTitle: workspaceTitle(cwd),
      },
    }
    const mirror = createActiveMirror(command, messagePayload, { ...meta, cwd })
    const emitEvent = (event) => {
      mirror?.record(event)
      onEvent(event)
    }
    if (mirror) {
      emitEvent({
        kind: 'active_mirror',
        message: `Active mirror ready: ${mirror.currentFile}`,
        currentFile: mirror.currentFile,
        eventsFile: mirror.eventsFile,
        cwd,
      })
    }
    try {
      const reply = await runCodexForPayload(buildPrompt(messagePayload), messagePayload, emitEvent, cwd, meta)
      await mirror?.complete('completed', reply)
      return {
        ok: true,
        threadId: String(payload.threadId || ''),
        workspacePath: cwd,
        workspaceTitle: workspaceTitle(cwd),
        activeMirror: mirror ? {
          currentFile: mirror.currentFile,
          eventsFile: mirror.eventsFile,
        } : null,
        message: {
          role: 'assistant',
          text: reply,
          createdAt: new Date().toISOString(),
        },
      }
    } catch (error) {
      await mirror?.complete('failed', String(error?.message || error || 'codex_failed'))
      throw error
    }
  }

  const error = new Error('command_unknown')
  error.status = 404
  throw error
}

async function dataToText(data) {
  if (typeof data === 'string') return data
  if (Buffer.isBuffer(data)) return data.toString('utf8')
  if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  if (data?.arrayBuffer) return Buffer.from(await data.arrayBuffer()).toString('utf8')
  return String(data || '')
}

function buildHubSocketUrl(hubUrl) {
  const socketUrl = new URL(hubUrl)
  socketUrl.searchParams.set('pairingCode', PAIRING_CODE)
  socketUrl.searchParams.set('machineName', MACHINE_NAME)
  return socketUrl.toString()
}

function attachHubSocket(socket, hubUrl) {
  console.log(`[ai-workspace-bridge] hub connected: ${hubUrl}`)
  hubState.connected = true
  hubState.url = hubUrl
  hubState.lastConnectedAt = new Date().toISOString()
  hubState.lastError = ''
  void inspectCodexRuntime().then((runtime) => {
    try {
      socket.send(JSON.stringify({
        type: 'hello',
        machineName: MACHINE_NAME,
        host: os.hostname(),
        cwd: CODEX_CWD,
        runtimeStatus: runtime.ok ? 'ready' : 'error',
        runtimeError: runtime.error,
        runtimeCheckedAt: new Date().toISOString(),
        sentAt: new Date().toISOString(),
      }))
    } catch {}
  }).catch((error) => {
    try {
      socket.send(JSON.stringify({
        type: 'hello',
        machineName: MACHINE_NAME,
        host: os.hostname(),
        cwd: CODEX_CWD,
        runtimeStatus: 'error',
        runtimeError: `codex_runtime_check_failed:${String(error?.message || error)}`,
        runtimeCheckedAt: new Date().toISOString(),
        sentAt: new Date().toISOString(),
      }))
    } catch {}
  })

  socket.onmessage = async (event) => {
    let request = null
    try {
      request = JSON.parse(await dataToText(event.data))
    } catch {
      return
    }
    if (request?.type !== 'request') return
    const requestId = String(request.requestId || '')
    const emitEvent = (payload) => {
      try {
        socket.send(JSON.stringify({
          type: 'event',
          requestId,
          event: {
            ...(payload || {}),
            at: new Date().toISOString(),
          },
        }))
      } catch {}
    }
    try {
      const receivedAfter = elapsedFromIso(request?.payload?.client?.sentAt)
      emitEvent({
        kind: 'bridge_received',
        message: receivedAfter
          ? `Bridge received request after ${receivedAfter}.`
          : 'Bridge received request.',
      })
      const payload = await handleBridgeCommand(String(request.command || ''), request.payload || {}, emitEvent, { requestId })
      socket.send(JSON.stringify({ type: 'response', requestId, ok: true, payload }))
    } catch (error) {
      emitEvent({ kind: 'error', message: String(error?.message || error || 'bridge_agent_failed') })
      socket.send(JSON.stringify({
        type: 'response',
        requestId,
        ok: false,
        status: Number(error?.status || 502),
        error: String(error?.message || error || 'bridge_agent_failed'),
      }))
    }
  }
  socket.onerror = (event) => {
    const message = String(event?.message || 'websocket_error')
    hubState.lastError = message
    console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`)
  }
  socket.onclose = () => {
    hubState.connected = false
    hubState.lastDisconnectedAt = new Date().toISOString()
    hubState.lastError = 'hub_disconnected'
    console.error(`[ai-workspace-bridge] hub disconnected; reconnecting in ${HUB_RECONNECT_MS}ms (${hubUrl})`)
    setTimeout(() => connectHub(), HUB_RECONNECT_MS)
  }
}

function connectHub() {
  if (!HUB_URLS.length || !PAIRING_CODE) return
  if (typeof WebSocket === 'undefined') {
    hubState.lastError = 'websocket_runtime_unavailable'
    console.error('[ai-workspace-bridge] WebSocket is not available in this Node.js runtime; use Node.js 22+ for hub mode.')
    return
  }

  const attempts = []
  let reconnectScheduled = false
  let selected = false
  const scheduleReconnect = (delayMs, message) => {
    if (reconnectScheduled) return
    reconnectScheduled = true
    if (message) {
      hubState.lastError = message
      console.error(message)
    }
    for (const attempt of attempts) {
      try { attempt.socket.close() } catch {}
    }
    setTimeout(() => connectHub(), delayMs)
  }

  const openTimer = setTimeout(() => {
    if (selected) return
    scheduleReconnect(1000, `[ai-workspace-bridge] hub candidates unavailable after ${HUB_CONNECT_TIMEOUT_MS}ms; retrying.`)
  }, HUB_CONNECT_TIMEOUT_MS)

  for (const hubUrl of HUB_URLS) {
    let socketUrl = ''
    try {
      socketUrl = buildHubSocketUrl(hubUrl)
    } catch (error) {
      const message = `invalid_hub_url:${String(error?.message || error)}`
      hubState.lastError = message
      console.error(`[ai-workspace-bridge] invalid hub URL: ${String(error?.message || error)} (${hubUrl})`)
      continue
    }

    const socket = new WebSocket(socketUrl)
    const attempt = { socket, hubUrl, closed: false }
    attempts.push(attempt)
    socket.onopen = () => {
      if (selected) {
        try { socket.close() } catch {}
        return
      }
      selected = true
      clearTimeout(openTimer)
      for (const other of attempts) {
        if (other.socket !== socket) {
          try { other.socket.close() } catch {}
        }
      }
      attachHubSocket(socket, hubUrl)
    }
    socket.onerror = (event) => {
      if (!selected) {
        const message = String(event?.message || 'websocket_error')
        hubState.lastError = message
        console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`)
      }
    }
    socket.onclose = () => {
      attempt.closed = true
      if (selected || reconnectScheduled) return
      if (attempts.length && attempts.every((item) => item.closed)) {
        clearTimeout(openTimer)
        scheduleReconnect(1000, '[ai-workspace-bridge] all hub candidates closed before ready; retrying.')
      }
    }
  }

  if (!attempts.length) {
    clearTimeout(openTimer)
    scheduleReconnect(5000, '[ai-workspace-bridge] no valid hub candidates; retrying.')
  }
}

async function handle(req, res) {
  const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
  if (req.method === 'OPTIONS') return sendJson(res, 204, {})

  if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/health') {
    return sendJson(res, 200, await handleBridgeCommand('health'))
  }

  if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/conversations') {
    return sendJson(res, 200, await handleBridgeCommand('conversations'))
  }

  if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/workspaces') {
    return sendJson(res, 200, await handleBridgeCommand('workspaces'))
  }

  if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/message') {
    const payload = await readBody(req)
    try {
      return sendJson(res, 200, await handleBridgeCommand('message', payload, () => {}, { requestId: `local-${Date.now()}` }))
    } catch (error) {
      return sendJson(res, Number(error?.status || 502), { ok: false, error: String(error?.message || error || 'codex_failed') })
    }
  }

  if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/stop') {
    const payload = await readBody(req)
    return sendJson(res, 200, await handleBridgeCommand('stop', payload))
  }

  return sendJson(res, 404, { ok: false, error: 'not_found' })
}

const server = http.createServer((req, res) => {
  handle(req, res).catch((error) => {
    sendJson(res, 500, { ok: false, error: String(error?.message || error || 'worker_failed') })
  })
})

server.listen(PORT, HOST, () => {
  console.log(`[ai-workspace-bridge] http://${HOST}:${PORT}`)
  console.log(`[ai-workspace-bridge] codex: ${CODEX_BIN} ${CODEX_ARGS.join(' ')}`)
  console.log(`[ai-workspace-bridge] codex home: ${CODEX_HOME}`)
  console.log(`[ai-workspace-bridge] cwd: ${CODEX_CWD}`)
  if (HUB_URLS.length && PAIRING_CODE) console.log(`[ai-workspace-bridge] hub candidates: ${HUB_URLS.join(', ')}`)
})

connectHub()
'@

  $ndcAgentMcpServerCode = @'
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'

const ROOT = process.env.NDC_AGENT_MCP_ROOT || process.cwd()
const DEFAULT_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
const DEFAULT_SCHEMA_VERSION = 'v2.3.2'
const ENGINE_FETCH_TIMEOUT_MS = Number(process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || 8000)
const DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH = '/api/ai-workspace/assistant/v1/actions'
const ASSISTANT_ACTION_FETCH_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || ENGINE_FETCH_TIMEOUT_MS)
const context = parseContext()
const nodeCatalogCache = new Map()
let transportMode = null
let inputBuffer = Buffer.alloc(0)

const FALLBACK_NODE_CATALOG = [
  {
    name: 'webhook',
    displayName: 'Webhook',
    description: 'Starts a workflow from an HTTP webhook request.',
    group: ['trigger'],
    version: [2.1],
    defaults: { name: 'Webhook' },
    inputs: [],
    outputs: ['main'],
    properties: [
      { name: 'httpMethod', displayName: 'HTTP Method', type: 'options', default: 'POST' },
      { name: 'path', displayName: 'Path', type: 'string', default: '' },
      { name: 'responseMode', displayName: 'Response Mode', type: 'options', default: 'onReceived' },
    ],
  },
  {
    name: 'set',
    displayName: 'Set',
    description: 'Sets or edits item fields.',
    group: ['transform'],
    version: [3.4],
    defaults: { name: 'Set' },
    inputs: ['main'],
    outputs: ['main'],
    properties: [
      { name: 'assignments', displayName: 'Assignments', type: 'fixedCollection', default: {} },
      { name: 'options', displayName: 'Options', type: 'collection', default: {} },
    ],
  },
  {
    name: 'code',
    displayName: 'Code',
    description: 'Runs custom JavaScript code.',
    group: ['transform'],
    version: [2],
    defaults: { name: 'Code' },
    inputs: ['main'],
    outputs: ['main'],
    properties: [
      { name: 'mode', displayName: 'Mode', type: 'options', default: 'runOnceForAllItems' },
      { name: 'jsCode', displayName: 'JavaScript Code', type: 'string', default: 'return items;' },
    ],
  },
  {
    name: 'httpRequest',
    displayName: 'HTTP Request',
    description: 'Makes an HTTP request and returns the response.',
    group: ['input'],
    version: [4.2],
    defaults: { name: 'HTTP Request' },
    inputs: ['main'],
    outputs: ['main'],
    properties: [
      { name: 'method', displayName: 'Method', type: 'options', default: 'GET' },
      { name: 'url', displayName: 'URL', type: 'string', default: '' },
      { name: 'sendHeaders', displayName: 'Send Headers', type: 'boolean', default: false },
      { name: 'sendBody', displayName: 'Send Body', type: 'boolean', default: false },
    ],
  },
  {
    name: 'respondToWebhook',
    displayName: 'Respond to Webhook',
    description: 'Returns a response to a Webhook trigger.',
    group: ['output'],
    version: [1.1],
    defaults: { name: 'Respond to Webhook' },
    inputs: ['main'],
    outputs: ['main'],
    properties: [
      { name: 'respondWith', displayName: 'Respond With', type: 'options', default: 'json' },
      { name: 'responseBody', displayName: 'Response Body', type: 'json', default: '{}' },
    ],
  },
  {
    name: 'noOp',
    displayName: 'No Operation, do nothing',
    description: 'Passes data through without changing it.',
    group: ['transform'],
    version: [1],
    defaults: { name: 'No Operation, do nothing' },
    inputs: ['main'],
    outputs: ['main'],
    properties: [],
  },
]

function parseContext() {
  const raw = String(process.env.NDC_AGENT_MCP_CONTEXT || '').trim()
  let parsed = {}
  if (raw) {
    try { parsed = JSON.parse(raw) || {} } catch {}
  }
  const assistantActions = parseObject(
    parsed.assistantActions ||
    process.env.AI_WORKSPACE_ASSISTANT_ACTIONS ||
    {},
  )
  return {
    modeId: cleanString(parsed.modeId || 'ndc-agent-core'),
    workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''),
    workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''),
    agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''),
    agentNodeTitle: cleanString(parsed.agentNodeTitle || process.env.NDC_AGENT_MCP_NODE_TITLE || ''),
    roleId: cleanString(parsed.roleId || process.env.NDC_AGENT_MCP_ROLE_ID || ''),
    roleTitle: cleanString(parsed.roleTitle || process.env.NDC_AGENT_MCP_ROLE_TITLE || ''),
    apiBaseUrl: cleanApiBase(parsed.ndcAgentMcpApiBaseUrl || process.env.NDC_AGENT_MCP_API_BASE_URL || DEFAULT_API_BASE),
    schemaVersion: cleanString(parsed.schemaVersion || process.env.NDC_AGENT_MCP_SCHEMA_VERSION || DEFAULT_SCHEMA_VERSION),
    n8nMcpRefPath: cleanString(parsed.n8nMcpRefPath || process.env.NDC_AGENT_MCP_REF_PATH || path.resolve(ROOT, '..', 'tools', 'NDCMCP')),
    assistantActions,
    assistantActionOwner: parseObject(parsed.assistantActionOwner || {}),
    assistantActionGatewayUrl: cleanHttpUrl(
      parsed.assistantActionGatewayUrl ||
      process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL ||
      deriveAssistantActionGatewayUrlFromHub() ||
      '',
    ),
    assistantActionGatewayToken: cleanString(
      parsed.assistantActionGatewayToken ||
      process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN ||
      '',
      4000,
    ),
  }
}

function cleanString(value, max = 1000) {
  return String(value || '').trim().slice(0, max)
}

function parseObject(value, fallback = {}) {
  if (value && typeof value === 'object' && !Array.isArray(value)) return value
  const text = String(value || '').trim()
  if (!text) return fallback
  try {
    const parsed = JSON.parse(text)
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
  } catch {
    return fallback
  }
}

function cleanHttpUrl(value) {
  const text = cleanString(value, 2000).replace(/\/+$/, '')
  if (!text) return ''
  try {
    const url = new URL(text)
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
    url.username = ''
    url.password = ''
    return url.toString().replace(/\/+$/, '')
  } catch {
    return ''
  }
}

function cleanApiBase(value) {
  const raw = cleanString(value || DEFAULT_API_BASE)
  return raw.replace(/\/+$/, '') || DEFAULT_API_BASE
}

function uniqueStrings(values) {
  const result = []
  const seen = new Set()
  for (const value of values || []) {
    const clean = cleanString(value, 1000).replace(/\/+$/, '')
    if (!clean || seen.has(clean)) continue
    seen.add(clean)
    result.push(clean)
  }
  return result
}

function isLoopbackUrl(value) {
  try {
    const host = new URL(value).hostname.toLowerCase()
    return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')
  } catch {
    return false
  }
}

function isPrivateNetworkUrl(value) {
  try {
    const host = new URL(value).hostname.toLowerCase()
    if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true
    if (host.startsWith('192.168.')) return true
    if (host.startsWith('10.')) return true
    if (host.startsWith('169.254.')) return true
    const match = host.match(/^172\.(\d+)\./)
    return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
  } catch {
    return false
  }
}

function hubOriginToApiBase(value) {
  try {
    const url = new URL(cleanString(value, 1000))
    const host = url.hostname.toLowerCase()
    if (!host) return ''
    if (url.protocol === 'wss:') url.protocol = 'https:'
    else if (url.protocol === 'ws:') url.protocol = 'http:'
    else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
    url.pathname = ''
    url.search = ''
    url.hash = ''
    const origin = url.toString().replace(/\/+$/, '')
    if (host === 'ai-hub.nodedc.ru') {
      const pairingCode = cleanPairingCode(process.env.AI_BRIDGE_PAIRING_CODE)
      return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : ''
    }
    return `${origin}/api/ndc-agent-mcp`
  } catch {
    return ''
  }
}

function hubOriginToAssistantActionGateway(value) {
  try {
    const url = new URL(cleanString(value, 1000))
    const host = url.hostname.toLowerCase()
    if (!host) return ''
    if (url.protocol === 'wss:') url.protocol = 'https:'
    else if (url.protocol === 'ws:') url.protocol = 'http:'
    else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
    url.pathname = ''
    url.search = ''
    url.hash = ''
    const origin = url.toString().replace(/\/+$/, '')
    return `${origin}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}`
  } catch {
    return ''
  }
}

function deriveAssistantActionGatewayUrlFromHub() {
  const candidates = [
    hubOriginToAssistantActionGateway(process.env.AI_BRIDGE_HUB_URL),
    ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToAssistantActionGateway),
  ].filter(Boolean)
  return uniqueStrings(candidates)[0] || ''
}

function cleanPairingCode(value) {
  return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
}

function splitList(value) {
  return cleanString(value, 4000).split(/[,;\r\n]+/).map((item) => item.trim()).filter(Boolean)
}

function engineApiBaseCandidates() {
  const explicit = cleanApiBase(context.apiBaseUrl)
  const hubBases = [
    hubOriginToApiBase(process.env.AI_BRIDGE_HUB_URL),
    ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase),
  ]
  const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '')
  if (explicit && !isPrivateNetworkUrl(explicit)) {
    return uniqueStrings([explicit, envBase])
  }
  if (envBase && !isPrivateNetworkUrl(envBase)) {
    return uniqueStrings([envBase, explicit])
  }
  if (hubBases.some(Boolean)) {
    return uniqueStrings([...hubBases, envBase, explicit])
  }
  return uniqueStrings([explicit, envBase, DEFAULT_API_BASE])
}

function engineApiUrls(pathname) {
  const pathValue = String(pathname || '')
  return engineApiBaseCandidates().map((baseValue) => {
    try {
      const base = new URL(baseValue)
      if (pathValue.startsWith('/api/')) return `${base.origin}${pathValue}`
      if (pathValue.startsWith('/')) return `${baseValue}${pathValue}`
      return new URL(pathValue, `${baseValue}/`).toString()
    } catch {
      return ''
    }
  }).filter(Boolean)
}

function fetchFailureText(error) {
  const parts = [
    error?.name,
    error?.message,
    error?.cause?.code,
    error?.cause?.message,
  ].map((item) => cleanString(item, 300)).filter(Boolean)
  return parts.join(':') || 'fetch_failed'
}

function toolText(value) {
  const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
  return { content: [{ type: 'text', text }], structuredContent: typeof value === 'string' ? undefined : value }
}

function jsonRpcResult(id, result) {
  return { jsonrpc: '2.0', id, result }
}

function jsonRpcError(id, code, message, data) {
  return { jsonrpc: '2.0', id, error: { code, message: String(message || 'error'), ...(data ? { data } : {}) } }
}

function writeMessage(message) {
  const json = JSON.stringify(message)
  if (transportMode === 'content-length') {
    const body = Buffer.from(json, 'utf8')
    process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`)
    process.stdout.write(body)
    return
  }
  process.stdout.write(`${json}\n`)
}

async function readJson(file, fallback = null) {
  try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { return fallback }
}

async function loadNodeCatalog(version = context.schemaVersion) {
  const key = cleanString(version || DEFAULT_SCHEMA_VERSION)
  if (nodeCatalogCache.has(key)) return nodeCatalogCache.get(key)
  const remoteCatalog = await loadRemoteNodeCatalog(key)
  if (remoteCatalog.length) {
    nodeCatalogCache.set(key, remoteCatalog)
    return remoteCatalog
  }
  const direct = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', key, 'nodes.catalog.json')
  const fallback = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', DEFAULT_SCHEMA_VERSION, 'nodes.catalog.json')
  const raw = await readJson(direct, null) || await readJson(fallback, [])
  const catalog = Array.isArray(raw) && raw.length ? raw : FALLBACK_NODE_CATALOG
  nodeCatalogCache.set(key, catalog)
  return catalog
}

async function loadN8nMcpVersion() {
  const pkg = await readJson(path.join(context.n8nMcpRefPath, 'package.json'), null)
  return pkg?.version ? String(pkg.version) : '2.33.2'
}

function assistantActionGatewayEndpoint() {
  const base = cleanHttpUrl(context.assistantActionGatewayUrl)
  if (!base) return ''
  if (base.endsWith(DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH) || base.endsWith('/actions')) return base
  return `${base}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}`
}

function assistantActionIds() {
  return Array.isArray(context.assistantActions?.actionIds)
    ? context.assistantActions.actionIds.map((item) => cleanString(item, 200)).filter(Boolean)
    : []
}

function assistantActionOwnerHeaders() {
  const owner = parseObject(context.assistantActionOwner, {})
  const headers = {}
  const put = (name, value) => {
    const text = cleanString(value, 1000)
    if (text) headers[name] = text
  }
  put('x-nodedc-user-id', owner.userId || owner.user_id)
  put('x-nodedc-user-email', owner.email)
  put('x-nodedc-user-role', owner.role)
  const groups = Array.isArray(owner.groups)
    ? owner.groups.map((item) => cleanString(item, 120)).filter(Boolean).join(',')
    : owner.groups
  put('x-nodedc-user-groups', groups)
  return headers
}

async function assistantActionFetch(payload = {}) {
  const endpoint = assistantActionGatewayEndpoint()
  const token = context.assistantActionGatewayToken
  if (!endpoint || !token) {
    return {
      ok: false,
      decision: 'assistant_action_gateway_unavailable',
      reason: 'Assistant action gateway URL/token is not configured for this run.',
      actionIds: assistantActionIds(),
    }
  }

  let res = null
  let text = ''
  let json = null
  try {
    res = await fetch(endpoint, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...assistantActionOwnerHeaders(),
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(ASSISTANT_ACTION_FETCH_TIMEOUT_MS),
    })
    text = await res.text().catch(() => '')
    try { json = text ? JSON.parse(text) : null } catch {}
  } catch (error) {
    const out = new Error(`assistant_action_gateway_fetch_failed:${fetchFailureText(error)}`)
    out.payload = { decision: 'assistant_action_gateway_fetch_failed' }
    throw out
  }

  if (!res.ok || json?.ok === false) {
    const out = new Error(json?.message || json?.error || `assistant_action_http_${res.status}`)
    out.status = res.status
    out.payload = json && typeof json === 'object'
      ? json
      : { status: res.status, preview: cleanString(text, 400) }
    throw out
  }
  if (!json || typeof json !== 'object') {
    const out = new Error(`assistant_action_non_json_response:${res.status}`)
    out.status = 502
    out.payload = { status: res.status, preview: cleanString(text, 400) }
    throw out
  }
  return json
}

function engineApiUrl(pathname) {
  return engineApiUrls(pathname)[0] || ''
}

async function loadRemoteNodeCatalog(version) {
  const query = new URLSearchParams({ kind: 'nodes', version: version || DEFAULT_SCHEMA_VERSION })
  const urls = engineApiUrls(`/api/n8n/schema?${query.toString()}`)
  for (const url of urls) {
    try {
      const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS) })
      if (!res.ok) continue
      const json = await res.json()
      if (Array.isArray(json?.nodes)) return json.nodes
    } catch {}
  }
  return []
}

function normalizeTypeName(value) {
  const raw = cleanString(value, 240)
  if (!raw) return ''
  if (raw.startsWith('n8n-nodes-base.')) return raw.slice('n8n-nodes-base.'.length)
  return raw
}

function fullNodeType(value) {
  const raw = cleanString(value, 240)
  if (!raw) return ''
  if (isPackageQualifiedNodeType(raw)) return raw
  return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}`
}

function isPackageQualifiedNodeType(value) {
  return /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+\.[a-z0-9._-]+$/i.test(cleanString(value, 240))
}

function catalogNodeRuntimeType(node) {
  const explicit = [node?.runtimeType, node?.fullType, node?.publicType, node?.type]
    .map((value) => cleanString(value, 240))
    .find(isPackageQualifiedNodeType)
  if (explicit) return explicit
  const packageName = cleanString(node?.packageName || node?.package, 160)
  const name = cleanString(node?.name, 160)
  if (name && /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+$/i.test(packageName)) {
    return `${packageName}.${name}`
  }
  return fullNodeType(node?.publicType || name)
}

function cleanWebhookSegment(value, fallback) {
  const out = cleanString(value, 240)
    .toLowerCase()
    .replace(/[^a-z0-9_-]+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-+|-+$/g, '')
  return out || fallback
}

function makeNdcWebhookPath(workflowId, nodeId) {
  return `ndc/${cleanWebhookSegment(workflowId, 'workflow')}/${cleanWebhookSegment(nodeId, 'agent')}/run`
}

function defaultSubworkflowGraph(workflowId = context.workflowId, nodeId = context.agentNodeId) {
  return {
    version: 1,
    nodes: [{
      id: 'webhook-trigger',
      type: 'n8nOp',
      position: { x: 0, y: 0 },
      data: {
        title: 'Webhook Trigger',
        n8n: {
          parameters: {
            httpMethod: 'POST',
            path: makeNdcWebhookPath(workflowId, nodeId),
            responseMode: 'onReceived',
            options: {},
          },
          type: 'n8n-nodes-base.webhook',
          typeVersion: 2.1,
          position: [0, 0],
          id: 'webhook-trigger',
          name: 'Webhook Trigger',
        },
        w: 220,
        h: 120,
        n8nTypeLabel: 'Webhook',
        n8nShowId: false,
        n8nOutputLabels: [],
      },
    }],
    edges: [],
    source: {
      createdBy: 'ndc-agent-mcp',
      initialized: 'default-webhook',
    },
  }
}

function normalizeGraphResult(value, workflowId = context.workflowId, nodeId = context.agentNodeId) {
  const graph = value && typeof value === 'object' ? { ...value } : defaultSubworkflowGraph(workflowId, nodeId)
  graph.version = Number(graph.version || 1) || 1
  graph.nodes = Array.isArray(graph.nodes) && graph.nodes.length ? graph.nodes : defaultSubworkflowGraph(workflowId, nodeId).nodes
  graph.edges = Array.isArray(graph.edges) ? graph.edges : []
  return graph
}

function versionSummary(version) {
  if (Array.isArray(version)) return version
  if (version === undefined || version === null) return []
  return [version]
}

function compactNodeDefinition(node, maxProperties = 80) {
  const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : []
  return {
    name: node?.name || '',
    publicType: node?.publicType || node?.name || '',
    fullType: catalogNodeRuntimeType(node),
    displayName: node?.displayName || node?.name || '',
    description: node?.description || '',
    group: node?.group || [],
    versions: versionSummary(node?.version),
    defaults: node?.defaults || {},
    inputs: node?.inputs || [],
    outputs: node?.outputs || [],
    credentials: node?.credentials || [],
    properties,
    propertiesTruncated: Array.isArray(node?.properties) && node.properties.length > properties.length,
  }
}

async function engineFetch(pathname, options = {}) {
  const urls = engineApiUrls(pathname)
  const failures = []
  for (const url of urls) {
    let res = null
    try {
      res = await fetch(url, {
        ...options,
        redirect: 'manual',
        signal: options.signal || AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS),
        headers: {
          Accept: 'application/json',
          ...(options.body ? { 'Content-Type': 'application/json' } : {}),
          ...(options.headers || {}),
        },
      })
    } catch (error) {
      failures.push(`${url} -> ${fetchFailureText(error)}`)
      continue
    }
    const text = await res.text().catch(() => '')
    let json = null
    try { json = text ? JSON.parse(text) : null } catch {}
    if (!res.ok || json?.ok === false) {
      const error = new Error(json?.message || json?.error || `engine_http_${res.status}`)
      error.status = res.status
      error.payload = { ...(json && typeof json === 'object' ? json : {}), url }
      throw error
    }
    if (!json || typeof json !== 'object') {
      const error = new Error(`engine_non_json_response:${res.status}`)
      error.status = 502
      error.payload = {
        url,
        status: res.status,
        contentType: res.headers.get('content-type') || '',
        preview: cleanString(text, 200),
      }
      throw error
    }
    return json
  }
  const error = new Error(`engine_fetch_failed:${failures.join(' | ') || 'no_engine_api_urls'}`)
  error.payload = { attempts: failures, apiBaseCandidates: engineApiBaseCandidates(), pathname }
  throw error
}

function targetFromArgs(args = {}) {
  const workflowId = cleanString(args.workflowId || context.workflowId)
  const nodeId = cleanString(args.nodeId || args.agentNodeId || context.agentNodeId)
  if (!workflowId || !nodeId) throw new Error('workflowId_and_nodeId_required')
  return { workflowId, nodeId }
}

async function handleGetContext() {
  const n8nMcpVersion = await loadN8nMcpVersion()
  return {
    ok: true,
    ...context,
    apiBaseCandidates: engineApiBaseCandidates(),
    n8nMcpVersion,
    assistantActionGatewayConfigured: Boolean(context.assistantActionGatewayUrl && context.assistantActionGatewayToken),
    contract: {
      sourceOfTruth: 'Engine second-level dc.subworkflow.json',
      writeApi: `${context.apiBaseUrl}/subworkflow/patch`,
      schemaApi: engineApiUrl(`/api/n8n/schema?kind=nodes&version=${encodeURIComponent(context.schemaVersion)}`),
      directCoreWritesAllowed: false,
      directFileWritesAllowed: false,
    },
  }
}

async function handleAssistantActionCall(args = {}) {
  const phase = cleanString(args.phase || args.mode || 'preview', 40)
  if (!['preview', 'dry-run', 'execute'].includes(phase)) throw new Error('assistant_action_phase_invalid')

  const input = parseObject(args.input || {}, {})
  const actionId = cleanString(args.actionId || input.actionId || '', 240)
  const intent = cleanString(args.intent || input.intent || '', 2000)
  if (actionId) input.actionId = actionId
  if (intent) input.intent = intent

  const availableIds = assistantActionIds()
  if (actionId && availableIds.length && !availableIds.includes(actionId)) {
    throw new Error(`assistant_action_not_advertised:${actionId}`)
  }
  if (!actionId && !intent) {
    throw new Error('assistant_action_input_required')
  }

  const confirmationToken = cleanString(
    args.confirmationToken ||
    args.confirmation?.token ||
    input.confirmationToken ||
    '',
    2000,
  )
  if (confirmationToken) input.confirmationToken = confirmationToken

  return assistantActionFetch({
    phase,
    input,
    ...(confirmationToken ? { confirmationToken } : {}),
  })
}

async function handleGetSubworkflow(args) {
  const target = targetFromArgs(args)
  const q = new URLSearchParams(target)
  const result = await engineFetch(`/subworkflow?${q.toString()}`)
  return {
    ok: true,
    ...(result && typeof result === 'object' ? result : {}),
    graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId),
  }
}

async function handleApplyPatch(args) {
  const target = targetFromArgs(args)
  const operations = Array.isArray(args.operations) ? args.operations : []
  if (!operations.length) throw new Error('operations_required')
  const result = await engineFetch('/subworkflow/patch', {
    method: 'POST',
    body: JSON.stringify({
      workflowId: target.workflowId,
      nodeId: target.nodeId,
      intent: cleanString(args.intent || 'NDC Agent MCP patch', 500),
      expectedUpdatedAt: cleanString(args.expectedUpdatedAt || ''),
      operations,
    }),
  })
  if (result?.graph) {
    return { ...result, graph: normalizeGraphResult(result.graph, target.workflowId, target.nodeId) }
  }
  const loaded = await handleGetSubworkflow(target)
  return {
    ok: true,
    ...(result && typeof result === 'object' ? result : {}),
    graph: loaded.graph,
    recoveredAfterEmptyPatchResponse: !result?.graph,
  }
}

async function handleSearchNodes(args) {
  const query = cleanString(args.query || args.q || '', 160).toLowerCase()
  const limit = Math.min(Math.max(Number(args.limit || 12) || 12, 1), 50)
  const catalog = await loadNodeCatalog(args.schemaVersion)
  const terms = query.split(/\s+/).filter(Boolean)
  const matches = catalog
    .map((node) => {
      const name = String(node?.name || '').toLowerCase()
      const displayName = String(node?.displayName || '').toLowerCase()
      const description = String(node?.description || '').toLowerCase()
      const groups = (Array.isArray(node?.group) ? node.group : []).map((item) => String(item || '').toLowerCase())
      const score = !terms.length ? 1 : terms.reduce((acc, term) => {
        if (name === term) return acc + 100
        if (displayName === term) return acc + 90
        if (name.includes(term)) return acc + 25
        if (displayName.includes(term)) return acc + 20
        if (groups.some((group) => group.includes(term))) return acc + 6
        if (description.includes(term)) return acc + 2
        return acc
      }, 0)
      return { node, score }
    })
    .filter((item) => item.score > 0)
    .sort((a, b) => b.score - a.score || String(a.node?.displayName || '').localeCompare(String(b.node?.displayName || '')))
    .slice(0, limit)
    .map(({ node }) => ({
      name: node?.name || '',
      publicType: node?.publicType || node?.name || '',
      fullType: catalogNodeRuntimeType(node),
      displayName: node?.displayName || node?.name || '',
      description: node?.description || '',
      group: node?.group || [],
      versions: versionSummary(node?.version),
    }))
  return { ok: true, query, count: matches.length, nodes: matches }
}

async function handleGetNodeDefinition(args) {
  const nodeType = normalizeTypeName(args.nodeType || args.type || args.name)
  if (!nodeType) throw new Error('nodeType_required')
  const catalog = await loadNodeCatalog(args.schemaVersion)
  const node = catalog.find((item) => (
    String(item?.name || '') === nodeType ||
    String(item?.publicType || '') === nodeType ||
    catalogNodeRuntimeType(item) === cleanString(args.nodeType || args.type || args.name)
  ))
  if (!node) throw new Error(`node_not_found:${nodeType}`)
  return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) }
}

async function handleValidateSubworkflow(args) {
  const target = (() => {
    try { return targetFromArgs(args) } catch { return { workflowId: context.workflowId, nodeId: context.agentNodeId } }
  })()
  const graph = args.graph && typeof args.graph === 'object'
    ? args.graph
    : normalizeGraphResult((await handleGetSubworkflow(args)).graph, target.workflowId, target.nodeId)
  const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
  const edges = Array.isArray(graph?.edges) ? graph.edges : []
  const nodeIds = new Set()
  const issues = []
  nodes.forEach((node, index) => {
    const id = cleanString(node?.id)
    if (!id) issues.push({ level: 'error', code: 'node_id_missing', index })
    if (id && nodeIds.has(id)) issues.push({ level: 'error', code: 'node_id_duplicate', nodeId: id })
    if (id) nodeIds.add(id)
    const typeName = cleanString(node?.data?.n8n?.type)
    if (!typeName) issues.push({ level: 'warn', code: 'node_type_missing', nodeId: id })
  })
  edges.forEach((edge, index) => {
    const source = cleanString(edge?.source)
    const target = cleanString(edge?.target)
    if (!source || !target) issues.push({ level: 'error', code: 'edge_endpoint_missing', index })
    if (source && !nodeIds.has(source)) issues.push({ level: 'error', code: 'edge_source_missing', edgeId: edge?.id, source })
    if (target && !nodeIds.has(target)) issues.push({ level: 'error', code: 'edge_target_missing', edgeId: edge?.id, target })
  })
  return {
    ok: !issues.some((issue) => issue.level === 'error'),
    nodesCount: nodes.length,
    edgesCount: edges.length,
    issues,
  }
}

const tools = [
  {
    name: 'ndc_get_context',
    description: 'Return selected NDC Agent Core target context and safety contract.',
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
  },
  {
    name: 'ndc_get_subworkflow',
    description: 'Read the selected Engine second-level workflow graph from dc.subworkflow.json.',
    inputSchema: {
      type: 'object',
      properties: {
        workflowId: { type: 'string' },
        nodeId: { type: 'string' },
      },
      additionalProperties: false,
    },
  },
  {
    name: 'ndc_apply_subworkflow_patch',
    description: 'Apply safe graph patch operations to the selected Engine second-level workflow.',
    inputSchema: {
      type: 'object',
      properties: {
        workflowId: { type: 'string' },
        nodeId: { type: 'string' },
        intent: { type: 'string' },
        expectedUpdatedAt: { type: 'string' },
        operations: {
          type: 'array',
          items: { type: 'object', additionalProperties: true },
        },
      },
      required: ['operations'],
      additionalProperties: false,
    },
  },
  {
    name: 'ndc_search_nodes',
    description: 'Search NDC node definitions from the pinned v2.3.2 schema catalog.',
    inputSchema: {
      type: 'object',
      properties: {
        query: { type: 'string' },
        limit: { type: 'number' },
        schemaVersion: { type: 'string' },
      },
      additionalProperties: false,
    },
  },
  {
    name: 'ndc_get_node_definition',
    description: 'Return a compact NDC node definition, including properties, credentials, inputs, and outputs.',
    inputSchema: {
      type: 'object',
      properties: {
        nodeType: { type: 'string' },
        schemaVersion: { type: 'string' },
        maxProperties: { type: 'number' },
      },
      required: ['nodeType'],
      additionalProperties: false,
    },
  },
  {
    name: 'ndc_validate_subworkflow',
    description: 'Validate a second-level workflow graph or the selected saved graph for basic structural issues.',
    inputSchema: {
      type: 'object',
      properties: {
        workflowId: { type: 'string' },
        nodeId: { type: 'string' },
        graph: { type: 'object', additionalProperties: true },
      },
      additionalProperties: false,
    },
  },
  {
    name: 'assistant_action_call',
    description: 'Call the NODE.DC assistant action layer after interpreting user intent. Use execute for read actions after structured action selection; use preview before any privileged/write action and execute only after explicit confirmation.',
    inputSchema: {
      type: 'object',
      properties: {
        phase: { type: 'string', enum: ['preview', 'dry-run', 'execute'] },
        actionId: { type: 'string' },
        intent: { type: 'string' },
        input: { type: 'object', additionalProperties: true },
        confirmationToken: { type: 'string' },
        confirmation: { type: 'object', additionalProperties: true },
      },
      additionalProperties: false,
    },
  },
]

async function callTool(name, args) {
  if (name === 'ndc_get_context') return handleGetContext()
  if (name === 'ndc_get_subworkflow') return handleGetSubworkflow(args)
  if (name === 'ndc_apply_subworkflow_patch') return handleApplyPatch(args)
  if (name === 'ndc_search_nodes') return handleSearchNodes(args)
  if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args)
  if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args)
  if (name === 'assistant_action_call') return handleAssistantActionCall(args)
  throw new Error(`unknown_tool:${name}`)
}

async function handleRequest(message) {
  const id = message?.id
  const method = String(message?.method || '')
  if (!method) return
  if (method.startsWith('notifications/')) return

  try {
    if (method === 'initialize') {
      return writeMessage(jsonRpcResult(id, {
        protocolVersion: message?.params?.protocolVersion || '2025-06-18',
        capabilities: { tools: {} },
        serverInfo: { name: 'ndc-agent-mcp', version: '0.1.0' },
      }))
    }
    if (method === 'ping') return writeMessage(jsonRpcResult(id, {}))
    if (method === 'tools/list') return writeMessage(jsonRpcResult(id, { tools }))
    if (method === 'tools/call') {
      const name = cleanString(message?.params?.name)
      const args = message?.params?.arguments && typeof message.params.arguments === 'object'
        ? message.params.arguments
        : {}
      const result = await callTool(name, args)
      return writeMessage(jsonRpcResult(id, toolText(result)))
    }
    if (method === 'resources/list') return writeMessage(jsonRpcResult(id, { resources: [] }))
    if (method === 'prompts/list') return writeMessage(jsonRpcResult(id, { prompts: [] }))
    return writeMessage(jsonRpcError(id, -32601, `method_not_found:${method}`))
  } catch (error) {
    return writeMessage(jsonRpcError(id, -32000, error?.message || error, error?.payload))
  }
}

function parseJsonMessage(raw) {
  try {
    return JSON.parse(raw)
  } catch {
    writeMessage(jsonRpcError(null, -32700, 'parse_error'))
    return null
  }
}

function consumeJsonLineMessages() {
  for (;;) {
    const newlineIndex = inputBuffer.indexOf(0x0a)
    if (newlineIndex < 0) return
    const line = inputBuffer.subarray(0, newlineIndex).toString('utf8')
    inputBuffer = inputBuffer.subarray(newlineIndex + 1)
    const trimmed = line.trim()
    if (!trimmed) continue
    const message = parseJsonMessage(trimmed)
    if (message) void handleRequest(message)
  }
}

function consumeContentLengthMessages() {
  for (;;) {
    const headerEnd = inputBuffer.indexOf('\r\n\r\n')
    const altHeaderEnd = headerEnd < 0 ? inputBuffer.indexOf('\n\n') : -1
    const boundary = headerEnd >= 0 ? headerEnd : altHeaderEnd
    if (boundary < 0) return

    const separatorLength = headerEnd >= 0 ? 4 : 2
    const header = inputBuffer.subarray(0, boundary).toString('utf8')
    const match = /content-length:\s*(\d+)/i.exec(header)
    if (!match) {
      inputBuffer = inputBuffer.subarray(boundary + separatorLength)
      writeMessage(jsonRpcError(null, -32600, 'content_length_missing'))
      continue
    }

    const contentLength = Number(match[1])
    const bodyStart = boundary + separatorLength
    const bodyEnd = bodyStart + contentLength
    if (inputBuffer.length < bodyEnd) return

    const body = inputBuffer.subarray(bodyStart, bodyEnd).toString('utf8')
    inputBuffer = inputBuffer.subarray(bodyEnd)
    const message = parseJsonMessage(body)
    if (message) void handleRequest(message)
  }
}

function consumeInputBuffer() {
  if (!inputBuffer.length) return

  if (!transportMode) {
    const trimmedStart = inputBuffer.toString('utf8', 0, Math.min(inputBuffer.length, 32)).trimStart()
    if (trimmedStart.startsWith('Content-Length:') || trimmedStart.toLowerCase().startsWith('content-length:')) {
      transportMode = 'content-length'
    } else if (trimmedStart.startsWith('{')) {
      transportMode = 'json-lines'
    } else if (inputBuffer.length < 32) {
      return
    } else {
      transportMode = 'json-lines'
    }
  }

  if (transportMode === 'content-length') consumeContentLengthMessages()
  else consumeJsonLineMessages()
}

process.stdin.on('data', (chunk) => {
  inputBuffer = Buffer.concat([inputBuffer, Buffer.from(chunk)])
  consumeInputBuffer()
})

process.stdin.on('end', () => {
  if (transportMode === 'json-lines') {
    const line = inputBuffer.toString('utf8').trim()
    inputBuffer = Buffer.alloc(0)
    if (!line) return
    const message = parseJsonMessage(line)
    if (message) void handleRequest(message)
  }
})

if (process.env.NDC_AGENT_MCP_DEBUG_JSONL === '1') {
  process.stdin.setEncoding('utf8')
}

process.on('uncaughtException', (error) => {
  writeMessage(jsonRpcError(null, -32000, error?.message || error))
})

process.on('unhandledRejection', (reason) => {
  writeMessage(jsonRpcError(null, -32000, reason?.message || reason))
})

if (process.stdin.isTTY) {
  process.stderr.write('ndc-agent-mcp expects JSON-RPC messages on stdin.\n')
}
'@

  Set-Content -Path $workerPath -Value $workerCode -Encoding UTF8
  Set-Content -Path $ndcAgentMcpServerPath -Value $ndcAgentMcpServerCode -Encoding UTF8

  $startCode = @"
`$ErrorActionPreference = 'Stop'
`$env:AI_BRIDGE_HOST = '0.0.0.0'
`$env:AI_BRIDGE_PORT = '$Port'
`$env:AI_BRIDGE_CODEX_CWD = $(ConvertTo-PsSingleQuoted $WorkspacePath)
`$env:AI_BRIDGE_CODEX_BIN = 'codex'
`$env:AI_BRIDGE_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]'
`$env:AI_BRIDGE_ALLOW_ORIGIN = '*'
`$env:AI_BRIDGE_HUB_URL = $(ConvertTo-PsSingleQuoted $HubUrl)
`$env:AI_BRIDGE_HUB_URLS = $(ConvertTo-PsSingleQuoted $hubUrlsText)
`$env:AI_BRIDGE_PAIRING_CODE = $(ConvertTo-PsSingleQuoted $PairingCode)
`$env:AI_BRIDGE_MACHINE_NAME = $(ConvertTo-PsSingleQuoted $resolvedMachineName)
`$env:AI_BRIDGE_ACTIVE_MIRROR_DIR = $(ConvertTo-PsSingleQuoted $activeMirror)
`$env:AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE = '1'
`$env:Path = $(ConvertTo-PsSingleQuoted $nodeDir) + ';' + $(ConvertTo-PsSingleQuoted $npmPrefix) + ';' + `$env:Path
Set-Location $(ConvertTo-PsSingleQuoted $Root)
& $(ConvertTo-PsSingleQuoted $nodeSource) $(ConvertTo-PsSingleQuoted $workerPath) *>> $(ConvertTo-PsSingleQuoted $logPath)
"@
  Set-Content -Path $startScript -Value $startCode -Encoding UTF8

  $watchdogCode = @"
`$ErrorActionPreference = 'SilentlyContinue'
`$startScript = $(ConvertTo-PsSingleQuoted $startScript)
`$logPath = $(ConvertTo-PsSingleQuoted $logPath)
`$healthUrl = $(ConvertTo-PsSingleQuoted "http://127.0.0.1:$Port$HealthPath")
`$healthy = `$false
try {
  `$request = [System.Net.HttpWebRequest]::Create(`$healthUrl)
  `$request.Method = 'GET'
  `$request.Proxy = `$null
  `$request.Timeout = 2000
  `$request.ReadWriteTimeout = 2000
  `$response = `$request.GetResponse()
  try {
    `$stream = `$response.GetResponseStream()
    `$reader = New-Object System.IO.StreamReader(`$stream)
    `$body = `$reader.ReadToEnd()
    `$healthy = `$response.StatusCode -eq 200 -and `$body -match '"ok"\s*:\s*true'
  } finally {
    `$response.Close()
  }
} catch {}
if (-not `$healthy) {
  try {
    Add-Content -Path `$logPath -Value ("[{0}] watchdog: health probe failed; starting bridge." -f (Get-Date).ToString('s')) -Encoding UTF8
  } catch {}
  Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `$startScript) -WindowStyle Hidden | Out-Null
}
"@
  Set-Content -Path $watchdogScript -Value $watchdogCode -Encoding UTF8

  $config = [ordered]@{
    service = 'NDC AI Workspace Bridge'
    port = $Port
    installRoot = $Root
    workspace = $WorkspacePath
    codexHome = $bridgeCodexHome
    worker = $workerPath
    startScript = $startScript
    watchdogScript = $watchdogScript
    watchdogEnabled = [bool]$EnableWatchdog
    taskName = $TaskName
    watchdogTaskName = "$TaskName Watchdog"
    healthPath = $HealthPath
    conversationPath = $ConversationPath
    activeMirror = $activeMirror
  }
  $config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
  Write-Ok "Installed to $Root"
  return @{
    WorkerPath = $workerPath
    StartScript = $startScript
    WatchdogScript = $watchdogScript
    ConfigPath = $configPath
    Logs = $logs
  }
}

function Register-BridgeAutostart {
  param(
    [string]$StartScript,
    [string]$WatchdogScript
  )
  $watchdogTaskName = "$TaskName Watchdog"
  if (-not $EnableWatchdog) {
    try {
      Stop-ScheduledTask -TaskName $watchdogTaskName -ErrorAction SilentlyContinue
    } catch {}
    try {
      Unregister-ScheduledTask -TaskName $watchdogTaskName -Confirm:$false -ErrorAction SilentlyContinue
      Write-Ok "Watchdog task disabled: $watchdogTaskName"
    } catch {}
  }

  if ($NoAutostart) {
    Write-Warn 'Autostart skipped by flag.'
    return
  }

  Write-Step 'Registering Windows autostart'
  $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`""
  $triggers = @()
  $triggers += New-ScheduledTaskTrigger -AtLogOn
  $triggers += New-ScheduledTaskTrigger -AtStartup
  $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Days 3650) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew

  Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Description 'NDC AI Workspace Bridge for NODE.DC AI Workspace' -RunLevel Highest -Force | Out-Null
  Write-Ok "Scheduled task registered: $TaskName"

  if ($EnableWatchdog -and $WatchdogScript) {
    $watchdogAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$WatchdogScript`""
    $watchdogTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
    $watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew
    Register-ScheduledTask -TaskName $watchdogTaskName -Action $watchdogAction -Trigger $watchdogTrigger -Settings $watchdogSettings -Description 'NDC AI Workspace Bridge watchdog' -RunLevel Highest -Force | Out-Null
    Write-Ok "Watchdog task registered: $watchdogTaskName"
  }
}

function Start-BridgeNow {
  param([string]$StartScript)

  Write-Step 'Starting bridge now'
  Stop-BridgeNow

  Start-Process -FilePath 'powershell.exe' -ArgumentList "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" -WindowStyle Hidden | Out-Null
  Write-Ok 'Bridge process started.'
}

function Stop-BridgeNow {
  try {
    Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
  } catch {}
  try {
    Stop-ScheduledTask -TaskName "$TaskName Watchdog" -ErrorAction SilentlyContinue
  } catch {}

  try {
    $escapedRoot = [Regex]::Escape($InstallRoot)
    $processes = Get-CimInstance Win32_Process |
      Where-Object {
        $_.CommandLine -match 'worker\.mjs' -and
        ($_.CommandLine -match $escapedRoot -or $_.CommandLine -match 'AIWorkspaceBridge')
      }
    foreach ($process in $processes) {
      try {
        Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
        Write-Ok "Stopped existing bridge process: $($process.ProcessId)"
      } catch {}
    }
  } catch {}
}

function Get-BridgeLogTail {
  $logPath = Join-Path $InstallRoot 'logs\bridge.log'
  if (-not (Test-Path $logPath)) { return '' }
  try {
    return ((Get-Content -Path $logPath -Tail 80 -ErrorAction Stop) -join [Environment]::NewLine)
  } catch {
    return ''
  }
}

function Get-PortOwnerReport {
  param([int]$LocalPort = $Port)

  try {
    $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue |
      Sort-Object OwningProcess, LocalAddress -Unique
    if (-not $listeners) { return '' }

    $lines = @()
    foreach ($listener in $listeners) {
      $processId = [int]$listener.OwningProcess
      $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue
      if ($process) {
        $commandLine = ([string]$process.CommandLine).Trim()
        if ($commandLine.Length -gt 500) { $commandLine = $commandLine.Substring(0, 500) + '...' }
        $lines += ("{0}:{1} PID {2} {3} {4}" -f $listener.LocalAddress, $listener.LocalPort, $processId, $process.Name, $commandLine)
      } else {
        $lines += ("{0}:{1} PID {2}" -f $listener.LocalAddress, $listener.LocalPort, $processId)
      }
    }
    return ($lines -join [Environment]::NewLine)
  } catch {
    return ''
  }
}

function Test-BridgeProcess {
  param([object]$Process)
  if (-not $Process) { return $false }
  $commandLine = [string]$Process.CommandLine
  if (-not $commandLine) { return $false }
  try {
    $escapedRoot = [Regex]::Escape($InstallRoot)
    return (
      $commandLine -match 'worker\.mjs' -and
      ($commandLine -match $escapedRoot -or $commandLine -match 'AIWorkspaceBridge')
    )
  } catch {
    return ($commandLine -match 'worker\.mjs' -and $commandLine -match 'AIWorkspaceBridge')
  }
}

function Get-BlockingPortOwners {
  param([int]$LocalPort)

  try {
    $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue |
      Sort-Object OwningProcess, LocalAddress -Unique
    if (-not $listeners) { return @() }

    $blocking = @()
    foreach ($listener in $listeners) {
      $processId = [int]$listener.OwningProcess
      $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue
      if (Test-BridgeProcess $process) { continue }
      $blocking += [pscustomobject]@{
        LocalAddress = $listener.LocalAddress
        LocalPort = $listener.LocalPort
        ProcessId = $processId
        Name = if ($process) { $process.Name } else { '' }
        CommandLine = if ($process) { [string]$process.CommandLine } else { '' }
      }
    }
    return @($blocking)
  } catch {
    return @()
  }
}

function Resolve-BridgePort {
  $requested = [int]$Port
  for ($candidate = $requested; $candidate -le ($requested + 20); $candidate++) {
    $blocking = @(Get-BlockingPortOwners -LocalPort $candidate)
    if ($blocking.Count -eq 0) {
      if ($candidate -ne $requested) {
        Write-Warn "TCP $requested is occupied by another service; using TCP $candidate for local bridge health."
      }
      return $candidate
    }
  }

  $report = Get-PortOwnerReport -LocalPort $requested
  if ($report) {
    throw "No free bridge port near $requested. Current listener on TCP ${requested}:`n$report"
  }
  throw "No free bridge port near $requested."
}

function Try-StartBridgeViaTask {
  if ($NoAutostart) { return }
  try {
    Start-ScheduledTask -TaskName $TaskName
    Write-Ok 'Autostart task started for validation.'
  } catch {
    Write-Warn "Could not start scheduled task: $($_.Exception.Message)"
  }
}

function Ensure-Firewall {
  if ($NoFirewall) {
    Write-Warn 'Firewall rule skipped by flag.'
    return
  }

  Write-Step 'Configuring Windows Firewall'
  $displayName = "NDC AI Workspace Bridge $Port"
  $existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue
  if ($existing) {
    $existing | Set-NetFirewallRule -Enabled True -Profile Domain,Private,Public -Direction Inbound -Action Allow
    Write-Ok "Firewall rule updated for TCP ${Port}: $displayName"
    return
  }

  New-NetFirewallRule -DisplayName $displayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Domain,Private,Public | Out-Null
  Write-Ok "Firewall rule added for TCP $Port"
}

function Normalize-PairingCode {
  param([string]$Value)
  if (-not $Value) { return '' }
  return (($Value -replace '[^A-Za-z0-9]', '').ToUpperInvariant())
}

function Get-LocalBridgeHealth {
  $healthUrl = "http://127.0.0.1:$Port$HealthPath"
  try {
    $request = [System.Net.HttpWebRequest]::Create($healthUrl)
    $request.Method = 'GET'
    $request.Proxy = $null
    $request.Timeout = 2000
    $request.ReadWriteTimeout = 2000
    $response = $request.GetResponse()
    try {
      $stream = $response.GetResponseStream()
      $reader = New-Object System.IO.StreamReader($stream)
      $body = $reader.ReadToEnd()
      $data = $null
      try { $data = $body | ConvertFrom-Json } catch {}
      return [pscustomobject]@{
        Ok = ($response.StatusCode -eq 200 -and $null -ne $data -and $data.ok -eq $true)
        Url = $healthUrl
        StatusCode = [int]$response.StatusCode
        Body = $body
        Data = $data
        Error = ''
      }
    } finally {
      $response.Close()
    }
  } catch {
    $statusCode = 0
    $body = ''
    $response = $_.Exception.Response
    if ($response) {
      try {
        $statusCode = [int]$response.StatusCode
        $stream = $response.GetResponseStream()
        if ($stream) {
          $reader = New-Object System.IO.StreamReader($stream)
          $body = $reader.ReadToEnd()
        }
      } catch {}
      try { $response.Close() } catch {}
    }
    return [pscustomobject]@{
      Ok = $false
      Url = $healthUrl
      StatusCode = $statusCode
      Body = $body
      Data = $null
      Error = $_.Exception.Message
    }
  }
}

function Wait-Health {
  Write-Step 'Checking bridge health'
  $lastError = ''
  $lastStatusCode = 0
  $lastBody = ''
  for ($i = 0; $i -lt 30; $i++) {
    $health = Get-LocalBridgeHealth
    if ($health.Ok) {
      Write-Ok "Health OK: $($health.Url)"
      return $health.Data
    }
    $lastError = $health.Error
    $lastStatusCode = [int]$health.StatusCode
    $lastBody = [string]$health.Body
    Start-Sleep -Seconds 1
  }
  Write-Warn "Bridge did not answer with JSON health yet: http://127.0.0.1:$Port$HealthPath"
  if ($lastStatusCode -gt 0) { Write-Warn "Last health HTTP status: $lastStatusCode" }
  if ($lastError) { Write-Warn "Last health error: $lastError" }
  if ($lastBody) {
    $bodyPreview = ($lastBody -replace '\s+', ' ').Trim()
    if ($bodyPreview.Length -gt 500) { $bodyPreview = $bodyPreview.Substring(0, 500) + '...' }
    Write-Warn "Last health body: $bodyPreview"
  }
  $portOwner = Get-PortOwnerReport
  if ($portOwner) {
    Write-Host "Current listener on TCP ${Port}:" -ForegroundColor Yellow
    Write-Host $portOwner -ForegroundColor Yellow
  } else {
    Write-Warn "No listener detected on TCP $Port."
  }
  return $null
}

function Wait-HubConnection {
  param([object]$InitialHealth)

  if (-not $HubUrl -or -not $PairingCode) {
    return $true
  }

  Write-Step 'Checking Cloud Hub connection'
  $expectedPairing = Normalize-PairingCode $PairingCode
  $lastHubError = ''
  $lastHubUrl = ''
  for ($i = 0; $i -lt 60; $i++) {
    $health = if ($i -eq 0 -and $InitialHealth) { $InitialHealth } else { (Get-LocalBridgeHealth).Data }
    if ($health) {
      $hub = $health.hub
      $hubConnected = ($health.hubConnected -eq $true)
      if ($hub) {
        $hubConnected = $hubConnected -or ($hub.connected -eq $true)
        $lastHubError = [string]$hub.lastError
        $lastHubUrl = [string]$hub.url
        $actualPairing = Normalize-PairingCode ([string]$hub.pairingCode)
        if ($hubConnected -and $actualPairing -eq $expectedPairing) {
          if ($lastHubUrl) {
            Write-Ok "Cloud Hub connected: $lastHubUrl"
          } else {
            Write-Ok 'Cloud Hub connected.'
          }
          return $true
        }
      }
    }
    Start-Sleep -Seconds 1
  }

  Write-Warn 'Cloud Hub connection was not confirmed.'
  if ($lastHubError) { Write-Warn "Last Hub error: $lastHubError" }
  $hubUrlCandidates = @(Get-HubUrlCandidates)
  if ($hubUrlCandidates.Count -gt 0) {
    Write-Host "Hub candidates:" -ForegroundColor Yellow
    foreach ($candidate in $hubUrlCandidates) {
      Write-Host "  $candidate" -ForegroundColor Yellow
    }
  }
  return $false
}

function Get-BridgeIps {
  $ips = @()
  try {
    $ips = Get-NetIPAddress -AddressFamily IPv4 |
      Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } |
      Sort-Object InterfaceMetric |
      Select-Object -ExpandProperty IPAddress -Unique
  } catch {}

  if (-not $ips -or $ips.Count -eq 0) {
    $ips = [System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) |
      Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.IPAddressToString -notlike '127.*' } |
      ForEach-Object { $_.IPAddressToString }
  }
  return @($ips)
}

function Print-Result {
  param(
    [string]$Root,
    [string]$WorkspacePath,
    [bool]$Healthy,
    [bool]$HubHealthy
  )

  $ips = Get-BridgeIps
  $endpoint = if ($ips.Count -gt 0) { "http://$($ips[0]):$Port" } else { "http://<THIS_MACHINE_IP>:$Port" }
  $hubUrlCandidates = @(Get-HubUrlCandidates)

  Write-Step 'Done'
  Write-Host "AI Workspace settings:"
  Write-Host "  Type: Codex worker"
  Write-Host "  Model: codex"
  if ($HubUrl -and $PairingCode) {
    Write-Host "  Connection: Cloud Hub" -ForegroundColor Green
    if ($HubHealthy) {
      Write-Host "  Hub connection: confirmed" -ForegroundColor Green
    } else {
      Write-Host "  Hub connection: not confirmed" -ForegroundColor Yellow
    }
  } else {
    Write-Host "  Bridge endpoint: $endpoint" -ForegroundColor Green
  }
  Write-Host ""
  Write-Host "Local health:"
  Write-Host "  http://127.0.0.1:$Port$HealthPath"
  Write-Host ""
  Write-Host "Install root:"
  Write-Host "  $Root"
  Write-Host "Workspace:"
  Write-Host "  $WorkspacePath"
  Write-Host "Autostart task:"
  Write-Host "  $TaskName"
  if ($hubUrlCandidates.Count -gt 0 -and $PairingCode) {
    Write-Host "Hub candidates:"
    foreach ($candidate in $hubUrlCandidates) {
      Write-Host "  $candidate"
    }
    Write-Host "Pairing code:"
    Write-Host "  $PairingCode" -ForegroundColor Green
  }

  if ($ips.Count -gt 1) {
    Write-Host ""
    Write-Host "Other possible endpoints:"
    foreach ($ip in $ips | Select-Object -Skip 1) {
      Write-Host "  http://${ip}:$Port"
    }
  }

  $copyText = @"
Type: Codex worker
Model: codex
Connection: $(if ($HubUrl -and $PairingCode) { 'Cloud Hub' } else { $endpoint })
Hub connection: $(if ($HubUrl -and $PairingCode) { if ($HubHealthy) { 'confirmed' } else { 'not confirmed' } } else { 'not used' })
Health: http://127.0.0.1:$Port$HealthPath
Workspace: $WorkspacePath
Hub candidates: $($hubUrlCandidates -join ', ')
Pairing code: $PairingCode
"@
  Write-InstallResult $copyText
  try {
    Set-Clipboard -Value $copyText
    Write-Ok 'Connection details copied to clipboard.'
  } catch {}

  if (-not $Healthy) {
    Write-Warn 'If Engine cannot connect, check Windows Firewall profile and bridge.log in the install root.'
    $logTail = Get-BridgeLogTail
    if ($logTail) {
      Write-Host ""
      Write-Host "bridge.log tail:" -ForegroundColor Yellow
      Write-Host $logTail
      try {
        Add-Content -Path (Get-ResultPath) -Value "`nbridge.log tail:`n$logTail" -Encoding UTF8
      } catch {}
    }
  }
}

function Main {
  if (-not $Confirmed) {
    Write-Host ""
    Write-Host "NDC AI Workspace Bridge will install or update Node.js, Codex CLI, a local bridge service, autostart, and firewall access."
    Write-Host "Target port: $Port"
  }

  Request-Elevation
  $workspacePath = Get-WorkspacePath
  Ensure-Node
  Ensure-Codex
  Ensure-CodexLogin
  Repair-CodexMcpConfig
  Ensure-AppMcpConfigs
  if (-not $AppMcpServersJson) { Ensure-OpsMcpConfig }
  Stop-BridgeNow
  $script:Port = Resolve-BridgePort
  $files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath
  Ensure-Firewall
  Register-BridgeAutostart -StartScript $files.StartScript -WatchdogScript $files.WatchdogScript
  Start-BridgeNow -StartScript $files.StartScript
  $health = Wait-Health
  if (-not $health) {
    Try-StartBridgeViaTask
    $health = Wait-Health
  }
  $hubHealthy = $true
  if ($health -and $HubUrl -and $PairingCode) {
    $hubHealthy = Wait-HubConnection -InitialHealth $health
  } elseif ($HubUrl -and $PairingCode) {
    $hubHealthy = $false
  }
  Print-Result -Root $InstallRoot -WorkspacePath $workspacePath -Healthy ([bool]$health -and [bool]$hubHealthy) -HubHealthy ([bool]$hubHealthy)
}

try {
  Main
} catch {
  $errorText = @"
NDC AI Workspace Bridge install failed.

Error:
$($_.Exception.Message)

Script:
$PSCommandPath

Install root:
$InstallRoot
"@
  Write-Host ""
  Write-Host $errorText -ForegroundColor Red
  Write-InstallResult $errorText
}
