Examples/Start-AgentChat.ps1

<#
.SYNOPSIS
    Interactive chat with OpenAI Codex App Server from PowerShell.
 
.DESCRIPTION
    A standalone REPL that connects to the Codex App Server and lets you
    have a multi-turn conversation. Type your messages, get responses,
    and the full conversation history is maintained in a single thread.
 
    Prerequisites:
      - npm i -g @openai/codex
      - codex.exe login (authenticate once via the native binary)
      - Set $env:CODEX_EXE to the native binary path (or edit $CodexExe below)
 
    Usage:
      .\Examples\Start-AgentChat.ps1
      .\Examples\Start-AgentChat.ps1 -Model "gpt-5.1-codex" -Cwd "D:\myproject"
      .\Examples\Start-AgentChat.ps1 -Model "gpt-4.1" -ApiKey $env:OPENAI_API_KEY
 
    Commands inside the chat:
      /quit, /exit, /q - end the session
      /new - start a fresh thread
      /model <name> - switch model
      /verbose - toggle verbose JSON-RPC output
#>


param(
    [string]$Model = "gpt-5.1-codex",
    [string]$Cwd = (Get-Location).Path,
    [string]$CodexExe = $env:CODEX_EXE,
    [string]$ApiKey
)


Import-Module $PSScriptRoot\..\ShowMarkdown.psm1 -Force

# ─────────────────────────────────────────────────────────────
# Embedded minimal client (no module dependency)
# ─────────────────────────────────────────────────────────────

$script:Verbose = $false
$script:NextId = 1

function Find-CodexBinary {
    param([string]$Hint)

    if ($Hint -and (Test-Path $Hint)) { return $Hint }

    # Search npm global
    $npmRoot = & npm root -g 2>$null
    if ($npmRoot) {
        $native = Join-Path $npmRoot "@openai\codex\node_modules\@openai\codex-win32-x64\vendor\x86_64-pc-windows-msvc\codex\codex.exe"
        if (Test-Path $native) { return $native }
        # arm64
        $native = Join-Path $npmRoot "@openai\codex\node_modules\@openai\codex-win32-arm64\vendor\aarch64-pc-windows-msvc\codex\codex.exe"
        if (Test-Path $native) { return $native }
        # Fallback: recursive
        $found = Get-ChildItem (Join-Path $npmRoot "@openai\codex") -Recurse -Filter "codex.exe" -ErrorAction SilentlyContinue |
        Where-Object { $_.Length -gt 1MB } | Select-Object -First 1
        if ($found) { return $found.FullName }
    }

    # Non-Windows
    $cmd = Get-Command codex -ErrorAction SilentlyContinue
    if ($cmd) { return $cmd.Source }

    return $null
}

function Send-Request {
    param($Writer, $Reader, [string]$Method, [hashtable]$Params = @{})

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

    while ($true) {
        $line = $Reader.ReadLine()
        if ($null -eq $line) { throw "codex app-server closed unexpectedly" }
        if ($script:Verbose) { Write-Host " <<< $line" -ForegroundColor DarkGray }

        $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
        }
    }
}

function Send-Notification {
    param($Writer, [string]$Method, [hashtable]$Params = @{})

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

function Read-TurnEvents {
    param($Writer, $Reader)

    $agentText = ""

    while ($true) {
        $line = $Reader.ReadLine()
        if ($null -eq $line) { break }
        if ($script:Verbose) { Write-Host " <<< $line" -ForegroundColor DarkGray }

        $parsed = $line | ConvertFrom-Json

        # Accumulate agent text deltas; rendering happens after completion.
        if ($parsed.method -eq "item/agentMessage/delta") {
            $delta = $parsed.params.delta
            if ($delta) {
                $agentText += $delta
            }
        }

        # Auto-accept approvals
        if ($parsed.method -eq "item/commandExecution/requestApproval" -or
            $parsed.method -eq "item/fileChange/requestApproval") {
            $resp = @{ id = $parsed.id; result = @{ decision = "accept" } }
            $json = $resp | ConvertTo-Json -Depth 10 -Compress
            $Writer.WriteLine($json)
            $Writer.Flush()
        }

        # Show command executions only in verbose mode
        if ($parsed.method -eq "item/started" -and $parsed.params.item.type -eq "commandExecution") {
            $cmd = $parsed.params.item.command
            if ($cmd -and $script:Verbose) {
                Write-Host "`n > $cmd" -ForegroundColor DarkYellow
            }
        }

        # Show errors
        if ($parsed.method -eq "error" -and $parsed.params.willRetry -eq $false) {
            Write-Host "`n[Error] $($parsed.params.error.message)" -ForegroundColor Red
        }

        # Done
        if ($parsed.method -eq "turn/completed") {
            return $agentText
        }
    }

    Write-Host "`n[Disconnected]" -ForegroundColor Yellow
    return $agentText
}

# ─────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────

$binary = Find-CodexBinary -Hint $CodexExe
if (-not $binary) {
    Write-Host "Cannot find codex.exe. Set `$env:CODEX_EXE or install: npm i -g @openai/codex" -ForegroundColor Red
    exit 1
}

Write-Host "╔══════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ Codex Chat - PowerShell Edition ║" -ForegroundColor Cyan
Write-Host "╚══════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host " Binary: $binary" -ForegroundColor DarkGray
Write-Host " Model: $Model" -ForegroundColor DarkGray
Write-Host " Cwd: $Cwd" -ForegroundColor DarkGray
Write-Host " Commands: /quit /new /model <name> /verbose" -ForegroundColor DarkGray
Write-Host ""

# Launch app-server
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $binary
$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 { Write-Host "Failed to start app-server: $_" -ForegroundColor Red; exit 1 }

$w = $proc.StandardInput
$r = [System.IO.StreamReader]::new($proc.StandardOutput.BaseStream, [System.Text.Encoding]::UTF8)

# Ensure console can render UTF-8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# Initialize
$initResult = Send-Request -Writer $w -Reader $r -Method "initialize" -Params @{
    clientInfo = @{
        name    = "powershell_chat"
        title   = "PowerShell Codex Chat"
        version = "1.0.0"
    }
}
Send-Notification -Writer $w -Method "initialized"

# Log in with API key if provided (overrides any stored ChatGPT account auth)
if ($ApiKey) {
    Send-Request -Writer $w -Reader $r -Method "account/login/start" -Params @{
        type   = "apiKey"
        apiKey = $ApiKey
    } | Out-Null
    # Drain login/completed and account/updated notifications
    $r.ReadLine() | Out-Null
    $r.ReadLine() | Out-Null
    Write-Host " Auth: API key" -ForegroundColor DarkGray
}

Write-Host " Connected!" -ForegroundColor Green
Write-Host ""

# Start first thread
function Start-NewThread {
    $result = Send-Request -Writer $w -Reader $r -Method "thread/start" -Params @{
        model          = $script:Model
        approvalPolicy = "never"
        sandbox        = "workspace-write"
        cwd            = $script:Cwd
    }
    # Drain thread/started and mcp_startup_complete notifications
    $r.ReadLine() | Out-Null
    $r.ReadLine() | Out-Null

    return $result.thread.id
}

$threadId = Start-NewThread
$turnCount = 0

# Chat loop
while ($true) {
    Write-Host "You: " -ForegroundColor Yellow -NoNewline
    $userInput = Read-Host

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

    # Handle commands
    switch -Regex ($userInput.Trim()) {
        '^/(quit|exit|q)$' {
            Write-Host "`nGoodbye!" -ForegroundColor Cyan
            try { $w.Close(); $proc.WaitForExit(3000); $proc.Kill() } catch { }
            $proc.Dispose()
            exit 0
        }
        '^/new$' {
            $threadId = Start-NewThread
            $turnCount = 0
            Write-Host " [New thread started]" -ForegroundColor DarkCyan
            continue
        }
        '^/model\s+(.+)$' {
            $script:Model = $Matches[1]
            $threadId = Start-NewThread
            $turnCount = 0
            Write-Host " [Model switched to $($script:Model), new thread started]" -ForegroundColor DarkCyan
            continue
        }
        '^/verbose$' {
            $script:Verbose = -not $script:Verbose
            Write-Host " [Verbose: $($script:Verbose)]" -ForegroundColor DarkCyan
            continue
        }
    }

    # Send turn
    $turnCount++
    Write-Host ""

    try {
        $turnResult = Send-Request -Writer $w -Reader $r -Method "turn/start" -Params @{
            threadId = $threadId
            input    = @( @{ type = "text"; text = $userInput } )
        }

        $response = Read-TurnEvents -Writer $w -Reader $r
        Show-Markdown $response
    }
    catch {
        Write-Host "[Error] $_" -ForegroundColor Red
    }

    Write-Host ""
}