workflows/default/systems/ui/modules/ControlAPI.psm1

<#
.SYNOPSIS
Control signal, whisper, and activity log API module

.DESCRIPTION
Provides control signal management (start/stop/pause/resume/reset),
operator whisper channel, and activity log tail streaming.
Extracted from server.ps1 for modularity.
#>


Import-Module (Join-Path $PSScriptRoot "..\..\runtime\modules\ConsoleSequenceSanitizer.psm1")

function Update-ActivityEventFields {
    param(
        [Parameter(Mandatory)]
        [object]$Event
    )

    if ($Event.PSObject.Properties['message']) {
        $Event.message = ConvertTo-SanitizedConsoleText $Event.message
    }

    return $Event
}

$script:Config = @{
    ControlDir = $null
    ProcessesDir = $null
    BotRoot = $null
}

function Initialize-ControlAPI {
    param(
        [Parameter(Mandatory)] [string]$ControlDir,
        [Parameter(Mandatory)] [string]$ProcessesDir,
        [Parameter(Mandatory)] [string]$BotRoot
    )
    $script:Config.ControlDir = $ControlDir
    $script:Config.ProcessesDir = $ProcessesDir
    $script:Config.BotRoot = $BotRoot
}

function Set-ControlSignal {
    param(
        [string]$Action,
        [string]$Mode = "execution"  # "execution", "analysis", or "both"
    )

    $controlDir = $script:Config.ControlDir
    $processesDir = $script:Config.ProcessesDir
    $botRoot = $script:Config.BotRoot
    $validActions = @("start", "stop", "pause", "resume", "reset")
    $validModes = @("execution", "analysis", "both")

    if ($Action -notin $validActions) {
        return @{ success = $false; message = "Invalid action: $Action" }
    }

    if ($Mode -and $Mode -notin $validModes) {
        $Mode = "execution"  # Default to execution if invalid
    }

    # Ensure control directory exists
    if (-not (Test-Path $controlDir)) {
        New-Item -Path $controlDir -ItemType Directory -Force | Out-Null
    }

    # Handle different actions
    switch ($Action) {
        "pause" {
            # Remove resume signal if exists, keep running signal
            $resumeSignal = Join-Path $controlDir "resume.signal"
            if (Test-Path $resumeSignal) { Remove-Item $resumeSignal -Force }

            # Create pause signal
            $signalFile = Join-Path $controlDir "pause.signal"
            @{
                action = $Action
                timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
            } | ConvertTo-Json | Set-Content -Path $signalFile -Force
        }
        "resume" {
            # Remove pause signal to resume from pause
            $pauseSignal = Join-Path $controlDir "pause.signal"
            if (Test-Path $pauseSignal) { Remove-Item $pauseSignal -Force }

            # Remove per-process .stop files to cancel pending stops
            if (Test-Path $processesDir) {
                Get-ChildItem -Path $processesDir -Filter "*.stop" -File -ErrorAction SilentlyContinue |
                    ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue }
            }
        }
        "stop" {
            # Remove pause signal if exists
            $pauseSignal = Join-Path $controlDir "pause.signal"
            if (Test-Path $pauseSignal) { Remove-Item $pauseSignal -Force }

            # Create stop files for all running/starting processes in registry
            if (Test-Path $processesDir) {
                $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue
                foreach ($pf in $procFiles) {
                    try {
                        $proc = Get-Content $pf.FullName -Raw | ConvertFrom-Json
                        if ($proc.status -in @('running', 'starting')) {
                            $stopFile = Join-Path $processesDir "$($proc.id).stop"
                            "stop" | Set-Content -Path $stopFile -Force
                        }
                    } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ }
                }
            }
        }
        "start" {
            # Start action - launch process(es) via unified launcher
            $launcherPath = Join-Path $botRoot "systems\runtime\launch-process.ps1"

            if (-not (Test-Path $launcherPath)) {
                return @{ success = $false; message = "Launcher script not found" }
            }

            # Check settings for debug mode and model selection
            $settingsFile = Join-Path $controlDir "ui-settings.json"
            $showDebug = $false
            $showVerbose = $false
            $analysisModel = "Opus"
            $executionModel = "Opus"
            if (Test-Path $settingsFile) {
                try {
                    $uiSettings = Get-Content $settingsFile -Raw | ConvertFrom-Json
                    $showDebug = [bool]$uiSettings.showDebug
                    $showVerbose = [bool]$uiSettings.showVerbose
                    if ($uiSettings.analysisModel) { $analysisModel = $uiSettings.analysisModel }
                    if ($uiSettings.executionModel) { $executionModel = $uiSettings.executionModel }
                } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ }
            }

            $launched = @()

            # Launch analysis process if mode is "analysis" or "both"
            if ($Mode -in @("analysis", "both")) {
                $args = @("-File", "`"$launcherPath`"", "-Type", "analysis", "-Continue", "-Model", $analysisModel)
                if ($showDebug) { $args += "-ShowDebug" }
                if ($showVerbose) { $args += "-ShowVerbose" }
                $startParams = @{ ArgumentList = $args }
                if ($IsWindows) { $startParams.WindowStyle = 'Normal' }
                Start-Process pwsh @startParams
                $launched += "analysis"
                Write-Status "Launched analysis process with model: $analysisModel" -Type Success
            }

            # Launch execution process if mode is "execution" or "both"
            if ($Mode -in @("execution", "both")) {
                $args = @("-File", "`"$launcherPath`"", "-Type", "execution", "-Continue", "-Model", $executionModel)
                if ($showDebug) { $args += "-ShowDebug" }
                if ($showVerbose) { $args += "-ShowVerbose" }
                $startParams = @{ ArgumentList = $args }
                if ($IsWindows) { $startParams.WindowStyle = 'Normal' }
                Start-Process pwsh @startParams
                $launched += "execution"
                Write-Status "Launched execution process with model: $executionModel" -Type Success
            }

            if ($launched.Count -eq 0) {
                return @{ success = $false; message = "No processes launched" }
            }

            return @{
                success = $true
                action = $Action
                mode = $Mode
                launched = $launched
                message = "Launched: $($launched -join ', ')"
            }
        }
        "reset" {
            # Clean up per-process .stop files
            if (Test-Path $processesDir) {
                Get-ChildItem -Path $processesDir -Filter "*.stop" -File -ErrorAction SilentlyContinue |
                    ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue }

                # Set all running/starting process registry entries to stopped
                $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue
                foreach ($pf in $procFiles) {
                    try {
                        $proc = Get-Content $pf.FullName -Raw | ConvertFrom-Json
                        if ($proc.status -in @('running', 'starting')) {
                            $proc.status = 'stopped'
                            $proc | Add-Member -NotePropertyName 'failed_at' -NotePropertyValue ((Get-Date).ToUniversalTime().ToString("o")) -Force
                            $proc | ConvertTo-Json -Depth 10 | Set-Content -Path $pf.FullName -Force -Encoding utf8NoBOM
                        }
                    } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ }
                }
            }

            # Clear remaining control signals (pause only — legacy signals removed)
            $pauseSignal = Join-Path $controlDir "pause.signal"
            if (Test-Path $pauseSignal) { Remove-Item $pauseSignal -Force }

            # Clear session lock
            $lockFile = Join-Path $botRoot "workspace\sessions\runs\session.lock"
            if (Test-Path $lockFile) { Remove-Item $lockFile -Force }

            # Update session state to stopped
            $stateFile = Join-Path $botRoot "workspace\sessions\runs\session-state.json"
            if (Test-Path $stateFile) {
                $state = Get-Content $stateFile -Raw | ConvertFrom-Json
                $state.status = "stopped"
                $state.current_task_id = $null
                $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile
            }

            Write-Status "Reset complete - cleared all stale state" -Type Success
        }
    }

    return @{
        success = $true
        action = $Action
        message = "Signal sent: $Action"
    }
}

function Send-Whisper {
    param(
        [string]$InstanceType,
        [string]$Message,
        [string]$Priority = "normal"
    )
    $processesDir = $script:Config.ProcessesDir

    # Find running processes of the given type from the process registry
    $targetProcs = @()
    if (Test-Path $processesDir) {
        $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue
        foreach ($pf in $procFiles) {
            try {
                $proc = Get-Content $pf.FullName -Raw | ConvertFrom-Json
                if ($proc.status -eq 'running' -and $proc.type -eq $InstanceType) {
                    $targetProcs += $proc
                }
            } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ }
        }
    }

    if ($targetProcs.Count -eq 0) {
        return @{ success = $false; error = "No $InstanceType instance running" }
    }

    # Send whisper to each matching process
    $sentTo = @()
    foreach ($proc in $targetProcs) {
        $whisperFile = Join-Path $processesDir "$($proc.id).whisper.jsonl"
        $whisper = @{
            instruction = $Message
            priority = $Priority
            timestamp = (Get-Date).ToUniversalTime().ToString("o")
        } | ConvertTo-Json -Compress

        Add-Content -Path $whisperFile -Value $whisper -Encoding utf8NoBOM
        $sentTo += $proc.id
    }

    Write-Status "Whisper sent to $($sentTo.Count) $InstanceType process(es)" -Type Success

    return @{
        success = $true
        instance_type = $InstanceType
        sent_to = $sentTo
    }
}

function Get-ActivityTail {
    param(
        [long]$Position = 0,
        [int]$TailLines = 0
    )
    $botRoot = $script:Config.BotRoot
    $logPath = Join-Path $botRoot ".control\activity.jsonl"

    if (-not (Test-Path $logPath)) {
        return @{ events = @(); position = 0 }
    }

    try {
        # If tail is requested (initial load), read last N lines
        if ($TailLines -gt 0 -and $Position -eq 0) {
            $stream = [System.IO.FileStream]::new(
                $logPath,
                [System.IO.FileMode]::Open,
                [System.IO.FileAccess]::Read,
                [System.IO.FileShare]::ReadWrite
            )
            $reader = [System.IO.StreamReader]::new($stream)
            $allText = $reader.ReadToEnd()
            $newPosition = $stream.Position
            $reader.Close()
            $stream.Close()
            $allLines = ($allText -split "`n") | Where-Object { $_.Trim() } | Select-Object -Last $TailLines
            $events = @()
            foreach ($line in $allLines) {
                if ($line) {
                    try {
                        $events += (Update-ActivityEventFields -Event ($line | ConvertFrom-Json))
                    } catch {
                        # Skip malformed lines
                    }
                }
            }

            return @{
                events = $events
                position = $newPosition
            }
        } else {
            # Normal streaming from position
            $stream = [System.IO.FileStream]::new(
                $logPath,
                [System.IO.FileMode]::Open,
                [System.IO.FileAccess]::Read,
                [System.IO.FileShare]::ReadWrite
            )
            $stream.Seek($Position, 'Begin') | Out-Null
            $reader = [System.IO.StreamReader]::new($stream)

            $events = @()
            while (-not $reader.EndOfStream) {
                $line = $reader.ReadLine()
                if ($line) {
                    try {
                        $events += (Update-ActivityEventFields -Event ($line | ConvertFrom-Json))
                    } catch {
                        # Skip malformed lines
                    }
                }
            }

            $newPosition = $stream.Position
            $reader.Close()
            $stream.Close()

            return @{
                events = $events
                position = $newPosition
            }
        }
    } catch {
        return @{
            events = @()
            position = 0
            error = "Failed to read activity log: $_"
        }
    }
}

Export-ModuleMember -Function @(
    'Initialize-ControlAPI',
    'Set-ControlSignal',
    'Send-Whisper',
    'Get-ActivityTail'
)