Eigenverft.Manifested.Agent.InvokeGeminiTask.ps1

<#
    Eigenverft.Manifested.Agent.InvokeGeminiTask
#>


function Get-GeminiSessionStorePath {
    [CmdletBinding()]
    param(
        [string]$LocalRoot = (Get-CodexLocalRoot)
    )

    return (Join-Path (Join-Path $LocalRoot 'sessions') 'named-gemini-sessions.json')
}

function Get-GeminiSessionKey {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$SessionName
    )

    return ($SessionName.Trim() -replace '\|', '_')
}

function Read-GeminiSessionMap {
    [CmdletBinding()]
    param(
        [string]$SessionStorePath = (Get-GeminiSessionStorePath)
    )

    $sessionMap = @{}
    if (-not (Test-Path -LiteralPath $SessionStorePath)) {
        return $sessionMap
    }

    try {
        $raw = Get-Content -LiteralPath $SessionStorePath -Raw
        if (-not [string]::IsNullOrWhiteSpace($raw)) {
            $obj = $raw | ConvertFrom-Json
            foreach ($property in $obj.PSObject.Properties) {
                $sessionMap[$property.Name] = $property.Value
            }
        }
    }
    catch {
        throw "Failed to read Gemini session store: $SessionStorePath"
    }

    return $sessionMap
}

function Write-GeminiSessionMap {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$SessionMap,

        [string]$SessionStorePath = (Get-GeminiSessionStorePath)
    )

    $sessionStoreRoot = Split-Path -Parent $SessionStorePath
    if (-not (Test-Path -LiteralPath $sessionStoreRoot)) {
        New-Item -ItemType Directory -Path $sessionStoreRoot -Force | Out-Null
    }

    ($SessionMap | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $SessionStorePath -Encoding UTF8
}

function Resolve-GeminiCommandPath {
    [CmdletBinding()]
    param()

    foreach ($candidate in @('gemini.cmd', 'gemini', 'gemini.ps1')) {
        $resolvedGemini = Get-Command $candidate -ErrorAction SilentlyContinue
        if (-not $resolvedGemini) {
            continue
        }

        if ($resolvedGemini.PSObject.Properties['Path'] -and $resolvedGemini.Path) {
            return $resolvedGemini.Path
        }

        return $resolvedGemini.Source
    }

    throw 'gemini was not found on PATH. Install the Gemini CLI or add it to PATH before using Invoke-GeminiTask.'
}

function ConvertFrom-GeminiJsonLine {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$Line
    )

    if ([string]::IsNullOrWhiteSpace($Line)) {
        return $null
    }

    $text = [string]$Line

    if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) {
        $text = $text.Substring(1)
    }

    try {
        return ($text | ConvertFrom-Json)
    }
    catch {
        return $null
    }
}

function Convert-GeminiProcessOutputToLines {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$Text
    )

    if ([string]::IsNullOrEmpty($Text)) {
        return @()
    }

    $lines = [regex]::Split($Text, "\r?\n")

    if ($lines.Count -gt 0 -and [string]::IsNullOrEmpty($lines[$lines.Count - 1])) {
        $lines = @($lines | Select-Object -First ($lines.Count - 1))
    }

    return @($lines)
}

function Get-GeminiInvocationLineRecords {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Invocation
    )

    $records =
        foreach ($sourceName in @('StdErrLines', 'StdOutLines')) {
            foreach ($line in @($Invocation.$sourceName)) {
                $text = [string]$line

                [pscustomobject]@{
                    Source = $sourceName
                    Line   = $text
                    Event  = ConvertFrom-GeminiJsonLine -Line $text
                }
            }
        }

    return @($records)
}

function ConvertTo-GeminiProcessArgument {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$Value
    )

    if ($null -eq $Value) {
        return '""'
    }

    if ($Value.Length -eq 0) {
        return '""'
    }

    if ($Value -notmatch '[\s"]') {
        return $Value
    }

    $builder = New-Object System.Text.StringBuilder
    [void]$builder.Append('"')
    $pendingBackslashes = 0

    foreach ($char in $Value.ToCharArray()) {
        if ($char -eq '\') {
            $pendingBackslashes++
            continue
        }

        if ($char -eq '"') {
            if ($pendingBackslashes -gt 0) {
                [void]$builder.Append(('\' * ($pendingBackslashes * 2)))
                $pendingBackslashes = 0
            }

            [void]$builder.Append('\"')
            continue
        }

        if ($pendingBackslashes -gt 0) {
            [void]$builder.Append(('\' * $pendingBackslashes))
            $pendingBackslashes = 0
        }

        [void]$builder.Append($char)
    }

    if ($pendingBackslashes -gt 0) {
        [void]$builder.Append(('\' * ($pendingBackslashes * 2)))
    }

    [void]$builder.Append('"')
    return $builder.ToString()
}

function ConvertTo-GeminiProcessArgumentString {
    [CmdletBinding()]
    param(
        [string[]]$Arguments
    )

    return ((@($Arguments) | ForEach-Object { ConvertTo-GeminiProcessArgument -Value $_ }) -join ' ')
}

function Invoke-GeminiProcess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$GeminiCommandPath,

        [string[]]$Arguments,

        [Parameter(Mandatory = $true)]
        [string]$Directory
    )

    $stdoutPath = Join-Path $env:TEMP ("gemini-stdout-{0}.log" -f ([Guid]::NewGuid().ToString('N')))
    $stderrPath = Join-Path $env:TEMP ("gemini-stderr-{0}.log" -f ([Guid]::NewGuid().ToString('N')))

    $argumentString = ConvertTo-GeminiProcessArgumentString -Arguments $Arguments

    try {
        $process = Start-Process `
            -FilePath $GeminiCommandPath `
            -ArgumentList $argumentString `
            -WorkingDirectory $Directory `
            -RedirectStandardOutput $stdoutPath `
            -RedirectStandardError $stderrPath `
            -Wait `
            -PassThru `
            -NoNewWindow

        $stdoutRaw = ''
        $stderrRaw = ''

        if (Test-Path -LiteralPath $stdoutPath) {
            $stdoutRaw = Get-Content -LiteralPath $stdoutPath -Raw
        }

        if (Test-Path -LiteralPath $stderrPath) {
            $stderrRaw = Get-Content -LiteralPath $stderrPath -Raw
        }

        [pscustomobject]@{
            ExitCode    = [int]$process.ExitCode
            StdOutRaw   = [string]$stdoutRaw
            StdErrRaw   = [string]$stderrRaw
            StdOutLines = @(Convert-GeminiProcessOutputToLines -Text $stdoutRaw)
            StdErrLines = @(Convert-GeminiProcessOutputToLines -Text $stderrRaw)
        }
    }
    finally {
        Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
    }
}

function Get-GeminiSessionListing {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$GeminiCommandPath,

        [Parameter(Mandatory = $true)]
        [string]$Directory
    )

    $invocation = Invoke-GeminiProcess -GeminiCommandPath $GeminiCommandPath -Arguments @('--list-sessions') -Directory $Directory

    $sessionIds = New-Object System.Collections.Generic.List[string]

    foreach ($record in @(Get-GeminiInvocationLineRecords -Invocation $invocation)) {
        $text = [string]$record.Line
        $match = [regex]::Match($text, '\[(?<id>[^\]]+)\]')

        if ($match.Success) {
            [void]$sessionIds.Add($match.Groups['id'].Value)
        }
    }

    [pscustomobject]@{
        Succeeded  = ($invocation.ExitCode -eq 0)
        ExitCode   = $invocation.ExitCode
        SessionIds = @($sessionIds | Select-Object -Unique)
        Lines      = @((Get-GeminiInvocationLineRecords -Invocation $invocation) | ForEach-Object { [string]$_.Line })
    }
}

function Test-GeminiListedSessionId {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$StoredSessionId,

        [string[]]$ListedSessionIds
    )

    if ([string]::IsNullOrWhiteSpace($StoredSessionId)) {
        return $false
    }

    foreach ($listedSessionId in @($ListedSessionIds)) {
        $candidate = [string]$listedSessionId

        if ([string]::IsNullOrWhiteSpace($candidate)) {
            continue
        }

        if ($StoredSessionId.Equals($candidate, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $true
        }

        if ($StoredSessionId.StartsWith($candidate, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $true
        }

        if ($candidate.StartsWith($StoredSessionId, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $true
        }
    }

    return $false
}

function Complete-GeminiAssistantMessageCapture {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Text.StringBuilder]$CurrentMessageBuilder,

        [Parameter(Mandatory = $true)]
        [ref]$LastAgentMessage
    )

    if ($CurrentMessageBuilder.Length -le 0) {
        return
    }

    $LastAgentMessage.Value = $CurrentMessageBuilder.ToString()
    [void]$CurrentMessageBuilder.Clear()
}

function Invoke-GeminiTask {
<#
.SYNOPSIS
Runs a Gemini non-interactive task and maintains wrapper-level named session state.
 
.DESCRIPTION
Thin PowerShell wrapper around Gemini CLI headless mode.
 
Wrapper-managed named sessions store:
- SessionName
- SessionId
- LastDirectory
- UpdatedUtc
 
Gemini-native sessions remain project-scoped.
This wrapper keeps a friendly session name that points at the last observed Gemini session id.
 
For named sessions, the wrapper uses `--output-format stream-json`
so it can capture the Gemini session id from the `init` event.
 
Before resuming a named session, the wrapper checks `gemini --list-sessions`
in the effective directory. If the stored Gemini session is no longer listed,
the wrapper starts a fresh Gemini session instead of forcing a stale resume id.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Prompt,

        [Alias('Path')]
        [string]$Directory,

        [Alias('Session')]
        [string]$SessionName,

        [bool]$AllowDangerous = $true,

        [bool]$Json = $true,

        [string]$OutputLastMessage,

        [string]$Model,

        [string[]]$AddDir
    )

    $geminiCmd = Resolve-GeminiCommandPath

    $currentDirectory = Resolve-CodexDirectory -Directory ((Get-Location).ProviderPath)
    $directoryProvided = $PSBoundParameters.ContainsKey('Directory')
    $requestedDirectory = $null

    if ($directoryProvided) {
        $requestedDirectory = Resolve-CodexDirectory -Directory $Directory
    }

    $sessionStorePath = Get-GeminiSessionStorePath
    $sessionStoreRoot = Split-Path -Parent $sessionStorePath

    if (-not (Test-Path -LiteralPath $sessionStoreRoot)) {
        New-Item -ItemType Directory -Path $sessionStoreRoot -Force | Out-Null
    }

    $sessionMap =
        if (Test-Path -LiteralPath $sessionStorePath) {
            Read-GeminiSessionMap -SessionStorePath $sessionStorePath
        }
        else {
            @{}
        }

    $sessionKey = $null
    $existingSession = $null
    $effectiveDirectory = $currentDirectory

    if (-not [string]::IsNullOrWhiteSpace($SessionName)) {
        $sessionKey = Get-GeminiSessionKey -SessionName $SessionName

        if ($sessionMap.ContainsKey($sessionKey)) {
            $existingSession = $sessionMap[$sessionKey]
        }

        if ($directoryProvided) {
            $effectiveDirectory = $requestedDirectory
        }
        elseif ($existingSession -and $existingSession.LastDirectory) {
            $effectiveDirectory = Resolve-CodexDirectory -Directory ([string]$existingSession.LastDirectory)
        }
        else {
            $effectiveDirectory = $currentDirectory
        }
    }
    elseif ($directoryProvided) {
        $effectiveDirectory = $requestedDirectory
    }

    $preRunListing = $null

    if ($existingSession -and $existingSession.SessionId) {
        $preRunListing = Get-GeminiSessionListing -GeminiCommandPath $geminiCmd -Directory $effectiveDirectory

        if ($preRunListing.Succeeded) {
            $storedSessionId = [string]$existingSession.SessionId

            if (-not (Test-GeminiListedSessionId -StoredSessionId $storedSessionId -ListedSessionIds $preRunListing.SessionIds)) {
                $existingSession = $null
            }
        }
    }

    $canResume = [bool](
        $existingSession -and
        $existingSession.SessionId
    )

    $effectiveOutputFormat =
        if (-not [string]::IsNullOrWhiteSpace($SessionName)) {
            'stream-json'
        }
        elseif ($Json) {
            'json'
        }
        else {
            'text'
        }

    if ([string]::IsNullOrWhiteSpace($OutputLastMessage) -and $effectiveOutputFormat -ne 'text') {
        $safeDirName = ([IO.Path]::GetFileName($effectiveDirectory)).Trim()
        if ([string]::IsNullOrWhiteSpace($safeDirName)) {
            $safeDirName = 'workspace'
        }

        $safeDirName = ($safeDirName -replace '[^A-Za-z0-9._-]', '_')

        if ([string]::IsNullOrWhiteSpace($SessionName)) {
            $OutputLastMessage = Join-Path $env:TEMP ("gemini-last-message-{0}-{1}.txt" -f $safeDirName, ([Guid]::NewGuid().ToString('N')))
        }
        else {
            $safeSessionFile = ($SessionName -replace '[^A-Za-z0-9._-]', '_')
            $OutputLastMessage = Join-Path $env:TEMP ("gemini-last-message-{0}-{1}.txt" -f $safeDirName, $safeSessionFile)
        }
    }

    $cargs = New-Object System.Collections.Generic.List[string]

    if ($canResume) {
        [void]$cargs.Add('--resume')
        [void]$cargs.Add([string]$existingSession.SessionId)
    }

    if (-not [string]::IsNullOrWhiteSpace($Model)) {
        [void]$cargs.Add('--model')
        [void]$cargs.Add($Model)
    }

    if ($AllowDangerous) {
        [void]$cargs.Add('--approval-mode')
        [void]$cargs.Add('yolo')
    }
    else {
        [void]$cargs.Add('--sandbox')
    }

    foreach ($dir in @($AddDir)) {
        if (-not [string]::IsNullOrWhiteSpace($dir)) {
            [void]$cargs.Add('--include-directories')
            [void]$cargs.Add((Resolve-CodexDirectory -Directory $dir))
        }
    }

    [void]$cargs.Add('--output-format')
    [void]$cargs.Add($effectiveOutputFormat)
    [void]$cargs.Add('-p')
    [void]$cargs.Add($Prompt)

    $argArray = $cargs.ToArray()
    $observedSessionId = $null
    $lastAgentMessage = $null
    $structuredErrorMessage = $null
    $exitCode = 0
    $currentAssistantMessageBuilder = New-Object System.Text.StringBuilder

    $invocation = Invoke-GeminiProcess -GeminiCommandPath $geminiCmd -Arguments $argArray -Directory $effectiveDirectory
    $exitCode = $invocation.ExitCode

    if ($effectiveOutputFormat -eq 'stream-json') {
        $streamJsonRecords = @(Get-GeminiInvocationLineRecords -Invocation $invocation)

        foreach ($record in $streamJsonRecords) {
            Write-Host ([string]$record.Line)
        }

        foreach ($record in $streamJsonRecords) {
            $evt = $record.Event

            if (-not $evt) {
                Complete-GeminiAssistantMessageCapture -CurrentMessageBuilder $currentAssistantMessageBuilder -LastAgentMessage ([ref]$lastAgentMessage)
                continue
            }

            if (
                $evt.type -eq 'init' -and
                $evt.PSObject.Properties.Match('session_id').Count -gt 0 -and
                $evt.session_id
            ) {
                $observedSessionId = [string]$evt.session_id

                if (-not [string]::IsNullOrWhiteSpace($SessionName)) {
                    $sessionMap[$sessionKey] = @{
                        SessionName   = $SessionName
                        SessionId     = $observedSessionId
                        LastDirectory = $effectiveDirectory
                        UpdatedUtc    = [DateTime]::UtcNow.ToString('o')
                    }

                    Write-GeminiSessionMap -SessionMap $sessionMap -SessionStorePath $sessionStorePath
                    $existingSession = $sessionMap[$sessionKey]
                }
            }

            $isAssistantMessage = [bool](
                $evt.type -eq 'message' -and
                $evt.PSObject.Properties.Match('role').Count -gt 0 -and
                $evt.role -eq 'assistant' -and
                $evt.PSObject.Properties.Match('content').Count -gt 0 -and
                $evt.content
            )

            if ($isAssistantMessage) {
                $isDeltaMessage =
                    [bool](
                        $evt.PSObject.Properties.Match('delta').Count -gt 0 -and
                        [bool]$evt.delta
                    )

                if (-not $isDeltaMessage) {
                    Complete-GeminiAssistantMessageCapture -CurrentMessageBuilder $currentAssistantMessageBuilder -LastAgentMessage ([ref]$lastAgentMessage)
                }

                [void]$currentAssistantMessageBuilder.Append([string]$evt.content)
                continue
            }

            Complete-GeminiAssistantMessageCapture -CurrentMessageBuilder $currentAssistantMessageBuilder -LastAgentMessage ([ref]$lastAgentMessage)

            if (
                $evt.type -eq 'error' -and
                $evt.PSObject.Properties.Match('message').Count -gt 0 -and
                $evt.message
            ) {
                $structuredErrorMessage = [string]$evt.message
            }

            if (
                $evt.type -eq 'result' -and
                $evt.PSObject.Properties.Match('error').Count -gt 0 -and
                $evt.error -and
                $evt.error.message
            ) {
                $structuredErrorMessage = [string]$evt.error.message
            }
        }

        Complete-GeminiAssistantMessageCapture -CurrentMessageBuilder $currentAssistantMessageBuilder -LastAgentMessage ([ref]$lastAgentMessage)
    }
    elseif ($effectiveOutputFormat -eq 'json') {
        foreach ($line in @($invocation.StdErrLines)) {
            Write-Host ([string]$line)
        }

        $rawStructuredOutput =
            if (-not [string]::IsNullOrWhiteSpace($invocation.StdOutRaw)) {
                [string]$invocation.StdOutRaw
            }
            else {
                [string]$invocation.StdErrRaw
            }

        if (-not [string]::IsNullOrWhiteSpace($rawStructuredOutput)) {
            Write-Host ($rawStructuredOutput.TrimEnd("`r", "`n"))

            try {
                $payload = $rawStructuredOutput | ConvertFrom-Json

                if ($payload.PSObject.Properties['session_id'] -and $payload.session_id) {
                    $observedSessionId = [string]$payload.session_id
                }

                if ($payload.PSObject.Properties['response'] -and $payload.response) {
                    $lastAgentMessage = [string]$payload.response
                }

                if ($payload.PSObject.Properties['error'] -and $payload.error -and $payload.error.message) {
                    $structuredErrorMessage = [string]$payload.error.message
                }
            }
            catch {
                # Ignore invalid JSON payloads.
            }
        }
    }
    else {
        foreach ($line in @($invocation.StdErrLines + $invocation.StdOutLines)) {
            Write-Host ([string]$line)
        }
    }

    if ($exitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($SessionName)) {
        $finalSessionId =
            if (-not [string]::IsNullOrWhiteSpace($observedSessionId)) {
                $observedSessionId
            }
            elseif ($existingSession -and $existingSession.SessionId) {
                [string]$existingSession.SessionId
            }
            else {
                $null
            }

        if (-not [string]::IsNullOrWhiteSpace($finalSessionId)) {
            $sessionMap[$sessionKey] = @{
                SessionName   = $SessionName
                SessionId     = $finalSessionId
                LastDirectory = $effectiveDirectory
                UpdatedUtc    = [DateTime]::UtcNow.ToString('o')
            }

            Write-GeminiSessionMap -SessionMap $sessionMap -SessionStorePath $sessionStorePath
            $existingSession = $sessionMap[$sessionKey]
        }

        [void](Get-GeminiSessionListing -GeminiCommandPath $geminiCmd -Directory $effectiveDirectory)
    }

    if (-not [string]::IsNullOrWhiteSpace($OutputLastMessage) -and -not [string]::IsNullOrWhiteSpace($lastAgentMessage)) {
        Set-Content -LiteralPath $OutputLastMessage -Value $lastAgentMessage -Encoding UTF8
    }

    if ($exitCode -ne 0) {
        if (-not [string]::IsNullOrWhiteSpace($structuredErrorMessage)) {
            throw "gemini command failed with exit code $exitCode. $structuredErrorMessage"
        }

        throw "gemini command failed with exit code $exitCode."
    }

    [pscustomobject]@{
        CommandPath       = $geminiCmd
        Directory         = $effectiveDirectory
        SessionName       = $SessionName
        SessionId         = if ($existingSession) { $existingSession.SessionId } else { $observedSessionId }
        Prompt            = $Prompt
        AllowDangerous    = [bool]$AllowDangerous
        Json              = [bool]($effectiveOutputFormat -ne 'text')
        OutputLastMessage = $OutputLastMessage
        LastAgentMessage  = $lastAgentMessage
        ExitCode          = $exitCode
        Resumed           = $canResume
        EffectiveArgs     = $argArray
    }
}