$ErrorActionPreference = "Continue" $BaseDir = "C:\MinerOpsAgent" $ConfigPath = Join-Path $BaseDir "agent-config.json" $LogDir = Join-Path $BaseDir "logs" $LogPath = Join-Path $LogDir "agent.log" $StatePath = Join-Path $BaseDir "agent-state.json" New-Item -ItemType Directory -Force -Path $LogDir | Out-Null function Write-AgentLog { param([string]$Message) $line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message Add-Content -Path $LogPath -Value $line -Encoding UTF8 } function Read-AgentConfig { if (!(Test-Path $ConfigPath)) { throw "Missing config file: $ConfigPath" } return Get-Content $ConfigPath -Raw | ConvertFrom-Json } function Save-AgentState { param( [string]$Status, [string]$Message, [object]$Extra = $null ) $state = [ordered]@{ last_run = (Get-Date).ToString("o") status = $Status message = $Message computer_name = $env:COMPUTERNAME extra = $Extra } $state | ConvertTo-Json -Depth 30 | Set-Content -Path $StatePath -Encoding UTF8 } function Invoke-AgentApi { param( [string]$Method, [string]$Path, [object]$Body = $null ) $config = Read-AgentConfig $hub = [string]$config.hub_url $secret = [string]$config.agent_secret $uri = $hub.TrimEnd("/") + $Path $headers = @{ "X-MinerOps-Agent-Secret" = $secret "X-MinerOps-Agent-ID" = [string]$config.agent_id } if ($Body -ne $null) { $json = $Body | ConvertTo-Json -Depth 100 -Compress return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 180 } return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -TimeoutSec 120 } function Send-AgentCheckin { $config = Read-AgentConfig $body = [ordered]@{ agent_id = [string]$config.agent_id site_name = [string]$config.site_name hostname = $env:COMPUTERNAME version = "0.4-command-queue" ip_address = "" local_time = (Get-Date).ToString("o") } if (-not [string]::IsNullOrWhiteSpace([string]$config.site_id)) { $body["site_id"] = [string]$config.site_id } if (-not [string]::IsNullOrWhiteSpace([string]$config.agent_zone_id)) { $body["agent_zone_id"] = [string]$config.agent_zone_id } return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body } function Get-KnownMinersFromHub { $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/known-miners" if ($result.miners) { return @($result.miners) } return @() } function Get-PendingAgentCommands { $config = Read-AgentConfig $agentId = [string]$config.agent_id $result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending" if ($result.commands) { return @($result.commands) } return @() } function Send-AgentCommandResult { param( [string]$CommandId, [string]$Status, [object]$ResultPayload = $null, [string]$ErrorMessage = $null ) $body = [ordered]@{ command_id = $CommandId status = $Status result_payload = $ResultPayload error_message = $ErrorMessage } return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body } function Poll-KnownMiners { param([object[]]$KnownMiners) $maxWorkers = 25 $jobs = New-Object System.Collections.ArrayList $results = New-Object System.Collections.ArrayList foreach ($miner in $KnownMiners) { while (($jobs | Where-Object { $_.State -eq "Running" }).Count -ge $maxWorkers) { $done = @($jobs | Where-Object { $_.State -ne "Running" }) foreach ($job in $done) { try { $jsonResult = Receive-Job $job -ErrorAction SilentlyContinue if ($jsonResult) { foreach ($line in @($jsonResult)) { if ($line) { [void]$results.Add(($line | ConvertFrom-Json)) } } } } catch { } Remove-Job $job -Force -ErrorAction SilentlyContinue [void]$jobs.Remove($job) } Start-Sleep -Milliseconds 200 } $minerJson = $miner | ConvertTo-Json -Depth 20 -Compress $job = Start-Job -ScriptBlock { param($minerJson) $Miner = $minerJson | ConvertFrom-Json function Invoke-WhatsMinerSummary { param( [string]$IpAddress, [int]$Port = 4028, [int]$TimeoutMs = 2000 ) $client = $null try { $client = New-Object System.Net.Sockets.TcpClient $async = $client.BeginConnect($IpAddress, $Port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { throw "Connect timeout" } $client.EndConnect($async) $client.ReceiveTimeout = $TimeoutMs $client.SendTimeout = $TimeoutMs $stream = $client.GetStream() $payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"summary`"}`n") $stream.Write($payload, 0, $payload.Length) try { $client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send) } catch { } $buffer = New-Object byte[] 65536 $chunks = New-Object System.Collections.ArrayList while ($true) { try { $read = $stream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } for ($i = 0; $i -lt $read; $i++) { [void]$chunks.Add([byte]$buffer[$i]) } if ($chunks.Count -gt 1048576) { break } } catch { break } } $raw = [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim() return @{ ok = $true raw = $raw error = $null } } catch { return @{ ok = $false raw = "" error = $_.Exception.Message } } finally { if ($client -ne $null) { $client.Close() } } } $started = Get-Date $summary = Invoke-WhatsMinerSummary -IpAddress ([string]$Miner.ip_address) $finished = Get-Date $online = $false if ($summary.ok -and $summary.raw.Length -gt 0) { $online = $true } $result = [ordered]@{ miner_id = [string]$Miner.miner_id ip_address = [string]$Miner.ip_address mac_address = [string]$Miner.mac_address room_name = [string]$Miner.room_name model = [string]$Miner.model previous_status = [string]$Miner.status online = $online started_at = $started.ToString("o") finished_at = $finished.ToString("o") duration_ms = [int](New-TimeSpan -Start $started -End $finished).TotalMilliseconds responses = @{ summary = @{ ok = $summary.ok raw = $summary.raw error = $summary.error } } } $result | ConvertTo-Json -Depth 50 -Compress } -ArgumentList $minerJson [void]$jobs.Add($job) } while ($jobs.Count -gt 0) { $done = @($jobs | Where-Object { $_.State -ne "Running" }) foreach ($job in $done) { try { $jsonResult = Receive-Job $job -ErrorAction SilentlyContinue if ($jsonResult) { foreach ($line in @($jsonResult)) { if ($line) { [void]$results.Add(($line | ConvertFrom-Json)) } } } } catch { } Remove-Job $job -Force -ErrorAction SilentlyContinue [void]$jobs.Remove($job) } Start-Sleep -Milliseconds 200 } return @($results) } function Send-MinerSnapshot { param([object[]]$Miners) $config = Read-AgentConfig $body = [ordered]@{ agent_id = [string]$config.agent_id site_name = [string]$config.site_name hostname = $env:COMPUTERNAME created_at = (Get-Date).ToString("o") miner_count = $Miners.Count miners = @($Miners) } return Invoke-AgentApi -Method "POST" -Path "/api/agent/miner-snapshots" -Body $body } function Convert-CidrToIpList { param([string]$Cidr) $Cidr = ([string]$Cidr).Trim() if ($Cidr -notmatch '^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})$') { throw "Invalid CIDR: $Cidr" } $a = [int]$Matches[1] $b = [int]$Matches[2] $c = [int]$Matches[3] $d = [int]$Matches[4] $prefix = [int]$Matches[5] if ($prefix -ne 22) { throw "This discovery scanner currently supports /22 ranges only. Got: $Cidr" } $list = New-Object System.Collections.ArrayList # /22 = 4 x /24 networks, 1024 addresses, 1022 usable. # Example: 10.30.0.0/22 means 10.30.0.1 through 10.30.3.254. for ($third = $c; $third -le ($c + 3); $third++) { for ($fourth = 1; $fourth -le 254; $fourth++) { [void]$list.Add("$a.$b.$third.$fourth") } } return @($list) } function Test-MinerPort { param( [string]$IpAddress, [int]$Port = 4028, [int]$TimeoutMs = 600 ) $client = $null try { $client = New-Object System.Net.Sockets.TcpClient $async = $client.BeginConnect($IpAddress, $Port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { return $false } $client.EndConnect($async) return $true } catch { return $false } finally { if ($client -ne $null) { $client.Close() } } } function Get-MinerSummaryRaw { param( [string]$IpAddress, [int]$Port = 4028, [int]$TimeoutMs = 1500 ) $client = $null try { $client = New-Object System.Net.Sockets.TcpClient $async = $client.BeginConnect($IpAddress, $Port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { throw "Connect timeout" } $client.EndConnect($async) $client.ReceiveTimeout = $TimeoutMs $client.SendTimeout = $TimeoutMs $stream = $client.GetStream() $payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"summary`"}`n") $stream.Write($payload, 0, $payload.Length) try { $client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send) } catch { } $buffer = New-Object byte[] 65536 $chunks = New-Object System.Collections.ArrayList while ($true) { try { $read = $stream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } for ($i = 0; $i -lt $read; $i++) { [void]$chunks.Add([byte]$buffer[$i]) } if ($chunks.Count -gt 1048576) { break } } catch { break } } return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim() } catch { return "" } finally { if ($client -ne $null) { $client.Close() } } } function Ensure-FastScannerLoaded { if ("MinerOpsFastScanner" -as [type]) { return } $code = @" using System; using System.Collections.Generic; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; public class MinerOpsScanTarget { public string IpAddress; public string RoomName; public int Port; } public class MinerOpsScanResult { public string IpAddress; public string RoomName; public bool Online; public string RawSummary; public string Error; public int DurationMs; } public static class MinerOpsFastScanner { public static MinerOpsScanResult[] Scan(MinerOpsScanTarget[] targets, int maxConcurrency, int timeoutMs) { var results = new List(); var locker = new object(); var semaphore = new SemaphoreSlim(maxConcurrency); var tasks = new List(); foreach (var target in targets) { semaphore.Wait(); var localTarget = target; tasks.Add(Task.Run(async delegate { try { var result = await ScanOne(localTarget, timeoutMs); lock (locker) { results.Add(result); } } finally { semaphore.Release(); } })); } Task.WaitAll(tasks.ToArray()); return results.ToArray(); } private static async Task ScanOne(MinerOpsScanTarget target, int timeoutMs) { var started = DateTime.UtcNow; var result = new MinerOpsScanResult(); result.IpAddress = target.IpAddress; result.RoomName = target.RoomName; result.Online = false; result.RawSummary = ""; result.Error = ""; result.DurationMs = 0; TcpClient client = null; try { client = new TcpClient(); var connectTask = client.ConnectAsync(target.IpAddress, target.Port); var timeoutTask = Task.Delay(timeoutMs); var winner = await Task.WhenAny(connectTask, timeoutTask); if (winner != connectTask) { result.Error = "Connect timeout"; return Finish(result, started); } await connectTask; client.ReceiveTimeout = timeoutMs; client.SendTimeout = timeoutMs; using (var stream = client.GetStream()) { var payload = Encoding.ASCII.GetBytes("{\"cmd\":\"summary\"}\n"); await stream.WriteAsync(payload, 0, payload.Length); try { client.Client.Shutdown(SocketShutdown.Send); } catch { } var buffer = new byte[65536]; var chunks = new List(); var readStarted = DateTime.UtcNow; while (true) { if ((DateTime.UtcNow - readStarted).TotalMilliseconds > timeoutMs) { break; } if (!stream.DataAvailable) { await Task.Delay(10); continue; } int read = await stream.ReadAsync(buffer, 0, buffer.Length); if (read <= 0) { break; } for (int i = 0; i < read; i++) { chunks.Add(buffer[i]); } if (chunks.Count > 1048576) { break; } } if (chunks.Count > 0) { result.RawSummary = Encoding.UTF8.GetString(chunks.ToArray()).Trim(); result.Online = result.RawSummary.Length > 0; } } } catch (Exception ex) { result.Error = ex.Message; } finally { if (client != null) { try { client.Close(); } catch {} } } return Finish(result, started); } private static MinerOpsScanResult Finish(MinerOpsScanResult result, DateTime started) { result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds; return result; } } "@ Add-Type -TypeDefinition $code -Language CSharp } function Discover-MinersInRanges { $config = Read-AgentConfig $ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true }) Ensure-FastScannerLoaded $targets = New-Object System.Collections.ArrayList foreach ($range in $ranges) { Write-AgentLog "Fast discovery range enabled: $($range.name) $($range.cidr)" $ips = Convert-CidrToIpList -Cidr ([string]$range.cidr) foreach ($ip in $ips) { $target = New-Object MinerOpsScanTarget $target.IpAddress = $ip $target.RoomName = [string]$range.name $target.Port = 4028 [void]$targets.Add($target) } } Write-AgentLog "Fast discovery targets built: $($targets.Count)" Ensure-FastScannerLoaded $maxConcurrency = 220 $timeoutMs = 900 Write-AgentLog "Fast discovery scan starting. Concurrency: $maxConcurrency TimeoutMs: $timeoutMs" $started = Get-Date $scanResults = [MinerOpsFastScanner]::Scan([MinerOpsScanTarget[]]$targets.ToArray([MinerOpsScanTarget]), $maxConcurrency, $timeoutMs) $finished = Get-Date $discoveries = New-Object System.Collections.ArrayList foreach ($result in $scanResults) { if ($result.Online -and $result.RawSummary -and $result.RawSummary.Length -gt 0) { $model = "WhatsMiner" $item = [ordered]@{ ip_address = [string]$result.IpAddress room_name = [string]$result.RoomName mac_address = "" model = $model hostname = "" miner_sn = "" raw_summary = [string]$result.RawSummary scan_duration_ms = [int]$result.DurationMs discovered_at = (Get-Date).ToString("o") } [void]$discoveries.Add($item) } } Write-AgentLog "Fast discovery scan complete. Responders: $($discoveries.Count). Duration seconds: $([math]::Round((New-TimeSpan -Start $started -End $finished).TotalSeconds, 2))" return @($discoveries) } function Send-AgentDiscoveries { param([object[]]$Discoveries) $config = Read-AgentConfig $body = [ordered]@{ agent_id = [string]$config.agent_id site_name = [string]$config.site_name hostname = $env:COMPUTERNAME created_at = (Get-Date).ToString("o") discovery_count = $Discoveries.Count discoveries = @($Discoveries) } return Invoke-AgentApi -Method "POST" -Path "/api/agent/discoveries" -Body $body } function Run-NewMinerDiscovery { Write-AgentLog "Starting new-miner discovery scan." $started = Get-Date $discoveries = Discover-MinersInRanges $finished = Get-Date Write-AgentLog "Discovery scan complete. Found responders: $($discoveries.Count). Sending to hub." $result = Send-AgentDiscoveries -Discoveries $discoveries $extra = @{ discovered = $discoveries.Count duration_seconds = [math]::Round((New-TimeSpan -Start $started -End $finished).TotalSeconds, 2) hub_result = $result } Save-AgentState -Status "ok" -Message "New-miner discovery scan sent." -Extra $extra return $extra } function Run-KnownMinerPoll { param([string]$Reason = "scheduled") Write-AgentLog "Checking in with hub." $checkin = Send-AgentCheckin Write-AgentLog "Getting known miners from hub." $knownMiners = Get-KnownMinersFromHub Write-AgentLog "Known miners received: $($knownMiners.Count). Polling summary. Reason: $Reason" $pollStarted = Get-Date $pollResults = Poll-KnownMiners -KnownMiners $knownMiners $pollFinished = Get-Date $online = @($pollResults | Where-Object { $_.online }).Count $offline = @($pollResults | Where-Object { -not $_.online }).Count Write-AgentLog "Polling complete. Online: $online Offline: $offline Total: $($pollResults.Count). Sending snapshot." $snapshot = Send-MinerSnapshot -Miners $pollResults $extra = @{ reason = $Reason known_miners = $knownMiners.Count polled = $pollResults.Count online = $online offline = $offline poll_duration_seconds = [math]::Round((New-TimeSpan -Start $pollStarted -End $pollFinished).TotalSeconds, 2) snapshot = $snapshot checkin = $checkin } Save-AgentState -Status "ok" -Message "Real miner summary snapshot sent." -Extra $extra return $extra } function Invoke-MinerPlainCommand { param( [Parameter(Mandatory=$true)] [string]$IpAddress, [Parameter(Mandatory=$true)] [string]$Command, [int]$Port = 4028, [int]$TimeoutMs = 2500 ) throw "physical_control_disabled_stage_a" } function Get-MinerOpsApi3Credential { param( [string]$Name, [string]$DefaultValue ) $config = Read-AgentConfig if ($config.PSObject.Properties.Name -contains $Name) { $value = [string]$config.$Name if ($value -and $value.Trim().Length -gt 0) { return $value.Trim() } } return $DefaultValue } function Get-MinerOpsApi3Token { param( [Parameter(Mandatory=$true)] [string]$Command, [Parameter(Mandatory=$true)] [string]$Password, [Parameter(Mandatory=$true)] [string]$Salt, [Parameter(Mandatory=$true)] [int64]$Timestamp ) $source = "$Command$Password$Salt$Timestamp" $bytes = [System.Text.Encoding]::UTF8.GetBytes($source) $sha = [System.Security.Cryptography.SHA256]::Create() try { $digest = $sha.ComputeHash($bytes) return ([Convert]::ToBase64String($digest)).Substring(0, 8) } finally { $sha.Dispose() } } function Read-MinerOpsExactBytes { param( [Parameter(Mandatory=$true)] [System.IO.Stream]$Stream, [Parameter(Mandatory=$true)] [int]$Length, [int]$TimeoutMs = 10000 ) $buffer = New-Object byte[] $Length $offset = 0 $started = Get-Date while ($offset -lt $Length) { if (((Get-Date) - $started).TotalMilliseconds -gt $TimeoutMs) { throw "Timed out reading $Length bytes from API3 stream." } try { $read = $Stream.Read($buffer, $offset, $Length - $offset) } catch { throw "API3 stream read failed: $($_.Exception.Message)" } if ($read -le 0) { throw "API3 stream ended early. Read $offset of $Length bytes." } $offset += $read } return $buffer } function Invoke-MinerOpsApi3Payload { param( [Parameter(Mandatory=$true)] [string]$IpAddress, [Parameter(Mandatory=$true)] [hashtable]$Payload, [int]$Port = 4433, [int]$TimeoutMs = 10000 ) throw "physical_control_disabled_stage_a" } function Get-MinerOpsApi3Salt { param( [Parameter(Mandatory=$true)] [string]$IpAddress, [int]$Port = 4433 ) $response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $Port -Payload @{ cmd = "get.device.info" param = $null } if ([int]$response.response.code -ne 0) { throw "API3 get.device.info failed: $($response.response | ConvertTo-Json -Compress)" } $salt = [string]$response.response.msg.salt if (!$salt) { throw "API3 get.device.info did not return salt." } return @{ salt = $salt salt_response = $response } } function Invoke-MinerOpsApi3Service { param( [Parameter(Mandatory=$true)] [string]$IpAddress, [Parameter(Mandatory=$true)] [ValidateSet("start", "stop", "restart")] [string]$ServiceAction ) throw "physical_control_disabled_stage_a" } function Normalize-MinerOpsIpAddress { param( [string]$IpAddress ) $value = ([string]$IpAddress).Trim() if ($value.Contains("/")) { $value = $value.Split("/")[0] } return $value } function Invoke-MinerOpsApi3AuthenticatedCommand { param( [Parameter(Mandatory=$true)] [string]$IpAddress, [Parameter(Mandatory=$true)] [string]$Command, [object]$Param = $null ) throw "physical_control_disabled_stage_a" } function Get-MinerOpsPayloadValue { param( [Parameter(Mandatory=$true)] $Payload, [Parameter(Mandatory=$true)] [string] $Name ) if ($null -eq $Payload) { return $null } if ($Payload -is [hashtable]) { if ($Payload.ContainsKey($Name)) { return $Payload[$Name] } return $null } $property = $Payload.PSObject.Properties[$Name] if ($property) { return $property.Value } return $null } function Invoke-MinerOpsLegacy4028Command { param( [Parameter(Mandatory=$true)] [string] $IpAddress, [Parameter(Mandatory=$true)] [string] $CommandJson, [int] $Port = 4028, [int] $TimeoutMs = 7000 ) throw "physical_control_disabled_stage_a" } function Invoke-MinerOpsM30Legacy4028Test { param( [Parameter(Mandatory=$true)] $Payload ) throw "physical_control_disabled_stage_a" } function Run-MinerControlCommand { param( [Parameter(Mandatory=$true)] [object]$Payload ) throw "physical_control_disabled_stage_a" } function Process-PendingCommands { $commands = @(Get-PendingAgentCommands) if ($commands.Count -le 0) { return 0 } Write-AgentLog "Pending commands received: $($commands.Count)" foreach ($cmd in $commands) { $commandId = [string]$cmd.command_id $commandType = [string]$cmd.command_type try { Write-AgentLog "Running command $commandId type $commandType" if ($commandType -eq "poll_known_miners_now") { $result = Run-KnownMinerPoll -Reason "manual_command" Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null Write-AgentLog "Command completed: $commandId" } elseif ($commandType -eq "miner_control") { $result = Run-MinerControlCommand -Payload $cmd.request_payload Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload $result | Out-Null Write-AgentLog "Miner control rejected by Stage A containment: $commandId" } elseif ($commandType -eq "enrich_match_discovery") { $payloadObj = $cmd.request_payload if ($payloadObj -is [string]) { try { $payloadObj = $payloadObj | ConvertFrom-Json } catch { $payloadObj = $null } } $ipAddress = "" if ($payloadObj -ne $null -and $payloadObj.PSObject.Properties.Name -contains "ip_address") { $ipAddress = [string]$payloadObj.ip_address } if (!$ipAddress) { throw "enrich_match_discovery command missing ip_address." } Write-AgentLog "Running Enrich / Match for discovery IP $ipAddress" $out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "EnrichOneMiner failed for $ipAddress. Output: $out" } Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload @{ ip_address = $ipAddress output = $out } | Out-Null Write-AgentLog "Enrich / Match command completed: $commandId" } elseif ($commandType -eq "discover_new_miners") { $result = Run-NewMinerDiscovery Send-AgentCommandResult -CommandId $commandId -Status "completed" -ResultPayload $result | Out-Null Write-AgentLog "Discovery command completed: $commandId" } else { Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload @{} -ErrorMessage "Unknown command type: $commandType" | Out-Null Write-AgentLog "Unknown command type failed: $commandType" } } catch { $msg = $_.Exception.Message Send-AgentCommandResult -CommandId $commandId -Status "failed" -ResultPayload @{} -ErrorMessage $msg | Out-Null Write-AgentLog "Command failed: $commandId ERROR: $msg" } } return $commands.Count } $controlModule = Join-Path $PSScriptRoot "MinerOpsControl.ps1" if (!(Test-Path $controlModule)) { throw "Missing typed control module: $controlModule" } . $controlModule Write-AgentLog "MinerOps Agent service script started." Save-AgentState -Status "starting" -Message "Agent starting." while ($true) { try { $config = Read-AgentConfig $interval = [int]$config.poll_interval_seconds if ($interval -lt 60) { $interval = 60 } $commandsProcessed = Process-PendingCommands if ($commandsProcessed -eq 0) { $null = Run-KnownMinerPoll -Reason "scheduled" } Write-AgentLog "Sleeping up to $interval seconds. Checking for web commands every 15 seconds." $slept = 0 while ($slept -lt $interval) { Start-Sleep -Seconds 15 $slept += 15 Process-PendingCommands | Out-Null } } catch { $msg = $_.Exception.Message Write-AgentLog "ERROR: $msg" Save-AgentState -Status "error" -Message $msg Start-Sleep -Seconds 30 } }