PSUnplugged.psm1

<#
.SYNOPSIS
    PowerShell client for the OpenAI Codex App Server (JSON-RPC over stdio).
 
.DESCRIPTION
    Spawns the native codex.exe app-server as a child process and communicates
    via newline-delimited JSON-RPC over stdin/stdout.
 
    Prerequisites:
      - npm i -g @openai/codex (installs the native Rust binary)
      - codex login (authenticate once, OR pass -ApiKey)
 
    On Windows the npm package installs a .ps1/.cmd wrapper that delegates to
    the native binary buried inside node_modules. This module auto-discovers
    the real codex.exe so Process.Start works correctly.
 
    If auto-discovery fails you can:
      - Set $env:CODEX_EXE to the full path of codex.exe
      - Pass -CodexPath to Start-CodexSession
      - Find it manually:
          Get-ChildItem (npm root -g) -Recurse -Filter codex.exe |
            Where-Object { $_.Length -gt 1MB }
 
.EXAMPLE
    # Basic interactive usage
    $session = Start-CodexSession
    $thread = New-CodexThread -Session $session -Cwd "C:\myproject"
    $result = Invoke-CodexTurn -Session $session -ThreadId $thread.id -Text "Summarize this repo."
    Write-Host $result.AgentText
    Stop-CodexSession -Session $session
 
.EXAMPLE
    # One-liner: ask a question and get the answer
    $session = Start-CodexSession
    $answer = Invoke-CodexQuestion -Session $session -Text "What does main.py do?"
    Write-Host $answer
    Stop-CodexSession -Session $session
#>


# ─────────────────────────────────────────────────────────────
# Session management
# ─────────────────────────────────────────────────────────────

function Start-CodexSession {
    <#
    .SYNOPSIS
        Launches codex app-server and performs the initialize handshake.
    .PARAMETER ClientName
        Identifier sent in clientInfo.name (default: "powershell_client").
    .PARAMETER ApiKey
        Optional OpenAI API key. If provided, login is performed after init.
    .PARAMETER CodexPath
        Path to the native codex.exe binary. If omitted, auto-discovered.
    #>

    [CmdletBinding()]
    param(
        [string]$ClientName = "powershell_client",
        [string]$ClientTitle = "PowerShell Codex Client",
        [string]$Version = "0.1.0",
        [string]$ApiKey,
        [string]$CodexPath = "codex"
    )

    # ── Resolve the native codex.exe binary ──
    $resolvedPath = $null

    if ($CodexPath -ne "codex") {
        # Explicit path provided
        if (-not (Test-Path $CodexPath)) {
            throw "Codex binary not found at: $CodexPath"
        }
        $resolvedPath = $CodexPath
    }
    else {
        # Auto-discovery

        # 1. Check CODEX_EXE environment variable
        if ($env:CODEX_EXE -and (Test-Path $env:CODEX_EXE)) {
            $resolvedPath = $env:CODEX_EXE
            Write-Verbose "Found codex via CODEX_EXE env var"
        }

        # 2. Search known npm global locations for the native binary
        if (-not $resolvedPath) {
            $npmRoots = @()
            $npmRoot = & npm root -g 2>$null
            if ($npmRoot) { $npmRoots += $npmRoot }
            if ($env:APPDATA) { $npmRoots += "$env:APPDATA\npm\node_modules" }
            if ($env:ProgramFiles) { $npmRoots += "$env:ProgramFiles\nodejs\node_modules" }

            foreach ($root in ($npmRoots | Select-Object -Unique)) {
                # x64
                $native = Join-Path $root "@openai\codex\node_modules\@openai\codex-win32-x64\vendor\x86_64-pc-windows-msvc\codex\codex.exe"
                if (Test-Path $native) { $resolvedPath = $native; break }
                # arm64
                $native = Join-Path $root "@openai\codex\node_modules\@openai\codex-win32-arm64\vendor\aarch64-pc-windows-msvc\codex\codex.exe"
                if (Test-Path $native) { $resolvedPath = $native; break }
            }
        }

        # 3. Fallback: recursive search for the real binary (>1 MB, not a wrapper)
        if (-not $resolvedPath) {
            $npmRoot = & npm root -g 2>$null
            if ($npmRoot) {
                $codexPkg = Join-Path $npmRoot "@openai\codex"
                if (Test-Path $codexPkg) {
                    $found = Get-ChildItem $codexPkg -Recurse -Filter "codex.exe" -ErrorAction SilentlyContinue |
                    Where-Object { $_.Length -gt 1MB } |
                    Select-Object -First 1
                    if ($found) { $resolvedPath = $found.FullName }
                }
            }
        }

        # 4. On non-Windows, try Get-Command directly (the binary is the binary)
        if (-not $resolvedPath -and -not $IsWindows) {
            $cmd = Get-Command codex -ErrorAction SilentlyContinue
            if ($cmd) { $resolvedPath = $cmd.Source }
        }

        if (-not $resolvedPath) {
            throw @"
Cannot find the native codex.exe binary.
  1. Install: npm i -g @openai/codex
  2. Or set: `$env:CODEX_EXE = 'C:\path\to\codex.exe'
  3. Or pass: Start-CodexSession -CodexPath 'C:\path\to\codex.exe'
 
  The binary is usually at:
    <npm-root>\@openai\codex\node_modules\@openai\codex-win32-x64\vendor\x86_64-pc-windows-msvc\codex\codex.exe
  Run 'npm root -g' to find your global npm directory.
  Or: Get-ChildItem (npm root -g) -Recurse -Filter codex.exe | Where-Object { `$_.Length -gt 1MB }
"@

        }
    }

    Write-Verbose "Using codex at: $resolvedPath"

    # ── Launch the process ──
    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    if ($resolvedPath -match '\.ps1$') {
        # .ps1 npm wrapper — launch through pwsh/powershell
        $psi.FileName = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh.exe" } else { "powershell.exe" }
        $psi.Arguments = "-NoProfile -NonInteractive -File `"$resolvedPath`" app-server"
    }
    elseif ($resolvedPath -match '\.(cmd|bat)$') {
        # .cmd npm wrapper — launch through cmd
        $psi.FileName = "cmd.exe"
        $psi.Arguments = "/c `"$resolvedPath`" app-server"
    }
    else {
        # Native .exe — launch directly
        $psi.FileName = $resolvedPath
        $psi.Arguments = "app-server"
    }
    $psi.UseShellExecute = $false
    $psi.RedirectStandardInput = $true
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.CreateNoWindow = $true

    try {
        $proc = [System.Diagnostics.Process]::Start($psi)
    }
    catch {
        throw "Failed to start codex app-server at '$resolvedPath': $_"
    }
    if (-not $proc) { throw "Failed to start codex app-server" }

    $session = [PSCustomObject]@{
        Process         = $proc
        Writer          = $proc.StandardInput
        Reader          = $proc.StandardOutput
        PendingReadTask = $null
        NextId          = 1
        Verbose         = $VerbosePreference -ne 'SilentlyContinue'
    }

    # ── Initialize handshake ──
    $initResult = Send-CodexRequest -Session $session -Method "initialize" -Params @{
        clientInfo = @{
            name    = $ClientName
            title   = $ClientTitle
            version = $Version
        }
    }
    Write-Verbose "Initialized: $($initResult | ConvertTo-Json -Depth 5)"

    # Send the required initialized notification
    Send-CodexNotification -Session $session -Method "initialized" -Params @{}

    # ── Optional API-key login ──
    if ($ApiKey) {
        $loginResult = Send-CodexRequest -Session $session -Method "account/login/start" -Params @{
            type   = "apiKey"
            apiKey = $ApiKey
        }
        # Drain the login/completed and account/updated notifications
        Read-CodexNotifications -Session $session -TimeoutMs 3000 | Out-Null
        Write-Verbose "Logged in with API key"
    }

    return $session
}

function Stop-CodexSession {
    <#
    .SYNOPSIS
        Gracefully shuts down the codex app-server process.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSCustomObject]$Session
    )
    try {
        $Session.Writer.Close()
        if (-not $Session.Process.WaitForExit(5000)) {
            $Session.Process.Kill()
        }
    }
    catch { }
    $Session.Process.Dispose()
    Write-Verbose "Codex session stopped"
}

# ─────────────────────────────────────────────────────────────
# Low-level JSON-RPC helpers
# ─────────────────────────────────────────────────────────────

function Receive-CodexLine {
    <#
    .SYNOPSIS
        Reads one stdout line using a single shared async read task.
    .PARAMETER TimeoutMs
        Optional timeout for waiting on a line. If omitted, waits indefinitely.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [int]$TimeoutMs
    )

    if (-not $Session.PendingReadTask) {
        $Session.PendingReadTask = $Session.Reader.ReadLineAsync()
    }

    $completed = if ($PSBoundParameters.ContainsKey('TimeoutMs')) {
        $Session.PendingReadTask.Wait($TimeoutMs)
    }
    else {
        $Session.PendingReadTask.Wait()
        $true
    }

    if (-not $completed) {
        return [PSCustomObject]@{ HasLine = $false; Line = $null }
    }

    $line = $Session.PendingReadTask.Result
    $Session.PendingReadTask = $null
    return [PSCustomObject]@{ HasLine = $true; Line = $line }
}

function Send-CodexRequest {
    <#
    .SYNOPSIS
        Sends a JSON-RPC request and waits for the matching response.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [Parameter(Mandatory)][string]$Method,
        [hashtable]$Params = @{}
    )

    $id = $Session.NextId++
    $msg = @{ method = $Method; id = $id; params = $Params }
    $json = $msg | ConvertTo-Json -Depth 20 -Compress
    Write-Verbose ">>> $json"
    $Session.Writer.WriteLine($json)
    $Session.Writer.Flush()

    # Read lines until we get the response with our id
    while ($true) {
        $read = Receive-CodexLine -Session $Session
        $line = $read.Line
        if ($null -eq $line) { throw "codex app-server closed unexpectedly" }
        Write-Verbose "<<< $line"

        $parsed = $line | ConvertFrom-Json
        if ($null -ne $parsed.id -and $parsed.id -eq $id) {
            if ($parsed.error) {
                throw "Codex error ($($parsed.error.code)): $($parsed.error.message)"
            }
            return $parsed.result
        }
        # Otherwise it's a notification — store or ignore
    }
}

function Send-CodexNotification {
    <#
    .SYNOPSIS
        Sends a JSON-RPC notification (no id, no response expected).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [Parameter(Mandatory)][string]$Method,
        [hashtable]$Params = @{}
    )

    $msg = @{ method = $Method; params = $Params }
    $json = $msg | ConvertTo-Json -Depth 20 -Compress
    Write-Verbose ">>> $json"
    $Session.Writer.WriteLine($json)
    $Session.Writer.Flush()
}

function Read-CodexNotifications {
    <#
    .SYNOPSIS
        Reads notifications/events from stdout until timeout or turn/completed.
    .PARAMETER WaitForTurnComplete
        If set, keeps reading until a turn/completed notification arrives.
    .PARAMETER TimeoutMs
        Maximum time to wait in milliseconds (default: 60000).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [int]$TimeoutMs = 60000,
        [switch]$WaitForTurnComplete
    )

    $events = [System.Collections.Generic.List[PSObject]]::new()
    $sw = [System.Diagnostics.Stopwatch]::StartNew()

    while ($sw.ElapsedMilliseconds -lt $TimeoutMs) {
        $remaining = $TimeoutMs - [int]$sw.ElapsedMilliseconds
        if ($remaining -le 0) { break }

        # Wait in short slices but keep using a single in-flight read task.
        $slice = [Math]::Min(500, $remaining)
        $read = Receive-CodexLine -Session $Session -TimeoutMs $slice
        if (-not $read.HasLine) { continue }

        $line = $read.Line
        if ($null -eq $line) { break }
        Write-Verbose "<<< $line"

        $parsed = $line | ConvertFrom-Json
        $events.Add($parsed)

        # Auto-accept approval requests (customize as needed)
        if ($parsed.method -eq "item/commandExecution/requestApproval" -or
            $parsed.method -eq "item/fileChange/requestApproval") {
            $approvalResponse = @{
                id     = $parsed.id
                result = @{ decision = "accept" }
            }
            $json = $approvalResponse | ConvertTo-Json -Depth 10 -Compress
            Write-Verbose ">>> $json (auto-approve)"
            $Session.Writer.WriteLine($json)
            $Session.Writer.Flush()
        }

        if ($WaitForTurnComplete -and $parsed.method -eq "turn/completed") {
            break
        }
    }

    return $events
}

# ─────────────────────────────────────────────────────────────
# Thread & Turn helpers
# ─────────────────────────────────────────────────────────────

function New-CodexThread {
    <#
    .SYNOPSIS
        Creates a new Codex conversation thread.
    .PARAMETER Model
        Model to use (default: gpt-5.1-codex).
    .PARAMETER Cwd
        Working directory for the agent.
    .PARAMETER ApprovalPolicy
        When to pause for approval: never, on-request, unless-trusted.
    .PARAMETER SandboxType
        Sandbox policy: read-only, workspace-write, danger-full-access.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [string]$Model = "gpt-5.1-codex",
        [string]$Cwd,
        [string]$ApprovalPolicy = "never",
        [ValidateSet("read-only", "workspace-write", "danger-full-access")]
        [string]$SandboxType = "workspace-write"
    )

    $params = @{
        model          = $Model
        approvalPolicy = $ApprovalPolicy
        sandbox        = $SandboxType
    }
    if ($Cwd) { $params.cwd = $Cwd }

    $result = Send-CodexRequest -Session $Session -Method "thread/start" -Params $params
    # Drain the thread/started notification
    Read-CodexNotifications -Session $Session -TimeoutMs 1000 | Out-Null

    return $result.thread
}

function Resume-CodexThread {
    <#
    .SYNOPSIS
        Resumes an existing thread by ID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [Parameter(Mandatory)][string]$ThreadId
    )

    $result = Send-CodexRequest -Session $Session -Method "thread/resume" -Params @{
        threadId = $ThreadId
    }
    return $result.thread
}

function Invoke-CodexTurn {
    <#
    .SYNOPSIS
        Sends user input to a thread, streams events, and returns the completed turn.
    .PARAMETER Text
        The user prompt text.
    .PARAMETER ImageUrl
        Optional image URL to include.
    .PARAMETER LocalImagePath
        Optional local image path to include.
    .PARAMETER TimeoutMs
        Max time to wait for turn completion (default: 120s).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [Parameter(Mandatory)][string]$ThreadId,
        [Parameter(Mandatory)][string]$Text,
        [string]$ImageUrl,
        [string]$LocalImagePath,
        [string]$Model,
        [string]$Effort,
        [int]$TimeoutMs = 120000
    )

    $input = @( @{ type = "text"; text = $Text } )
    if ($ImageUrl) { $input += @{ type = "image"; url = $ImageUrl } }
    if ($LocalImagePath) { $input += @{ type = "localImage"; path = $LocalImagePath } }

    $params = @{
        threadId = $ThreadId
        input    = $input
    }
    if ($Model) { $params.model = $Model }
    if ($Effort) { $params.effort = $Effort }

    $turnResult = Send-CodexRequest -Session $Session -Method "turn/start" -Params $params
    $turnId = $turnResult.turn.id

    # Stream events until turn/completed
    $events = Read-CodexNotifications -Session $Session -TimeoutMs $TimeoutMs -WaitForTurnComplete

    # Extract the final turn state and agent text
    $completedEvent = $events | Where-Object { $_.method -eq "turn/completed" } | Select-Object -Last 1
    $agentDeltas = $events | Where-Object { $_.method -eq "item/agentMessage/delta" }
    $agentText = ($agentDeltas | ForEach-Object { $_.params.delta }) -join ""

    $items = $events | Where-Object { $_.method -eq "item/completed" } |
    ForEach-Object { $_.params.item }

    return [PSCustomObject]@{
        TurnId    = $turnId
        Status    = if ($completedEvent) { $completedEvent.params.turn.status } else { "unknown" }
        AgentText = $agentText
        Items     = $items
        Events    = $events
        Turn      = if ($completedEvent) { $completedEvent.params.turn } else { $null }
    }
}

function Invoke-CodexQuestion {
    <#
    .SYNOPSIS
        Convenience: creates a thread, asks a question, returns the answer text.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][PSCustomObject]$Session,
        [Parameter(Mandatory)][string]$Text,
        [string]$Model = "gpt-5.1-codex",
        [string]$Cwd
    )

    $thread = New-CodexThread -Session $Session -Model $Model -Cwd $Cwd
    $result = Invoke-CodexTurn -Session $Session -ThreadId $thread.id -Text $Text
    return $result.AgentText
}

# ─────────────────────────────────────────────────────────────
# Utility functions
# ─────────────────────────────────────────────────────────────

function Get-CodexThreads {
    <#
    .SYNOPSIS
        Lists stored threads with optional pagination.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [int]$Limit = 25,
        [string]$Cursor
    )

    $params = @{ limit = $Limit }
    if ($Cursor) { $params.cursor = $Cursor }

    return Send-CodexRequest -Session $Session -Method "thread/list" -Params $params
}

function Get-CodexModels {
    <#
    .SYNOPSIS
        Lists available models.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session
    )

    return Send-CodexRequest -Session $Session -Method "model/list" -Params @{}
}

function Invoke-CodexCommand {
    <#
    .SYNOPSIS
        Runs a command in the Codex sandbox (no thread needed).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session,
        [Parameter(Mandatory)][string[]]$Command,
        [string]$Cwd,
        [int]$TimeoutMs = 10000
    )

    $params = @{
        command   = $Command
        timeoutMs = $TimeoutMs
    }
    if ($Cwd) { $params.cwd = $Cwd }

    return Send-CodexRequest -Session $Session -Method "command/exec" -Params $params
}

function Get-CodexAccount {
    <#
    .SYNOPSIS
        Returns current auth state.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Session
    )

    return Send-CodexRequest -Session $Session -Method "account/read" -Params @{
        refreshToken = $false
    }
}

# ─────────────────────────────────────────────────────────────
# Export
# ─────────────────────────────────────────────────────────────

Export-ModuleMember -Function @(
    'Start-CodexSession'
    'Stop-CodexSession'
    'New-CodexThread'
    'Resume-CodexThread'
    'Invoke-CodexTurn'
    'Invoke-CodexQuestion'
    'Get-CodexThreads'
    'Get-CodexModels'
    'Invoke-CodexCommand'
    'Get-CodexAccount'
    'Send-CodexRequest'
    'Send-CodexNotification'
    'Read-CodexNotifications'
)