workflows/default/systems/mcp/tools/steering-heartbeat/script.ps1

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

function Invoke-SteeringHeartbeat {
    <#
    .SYNOPSIS
    Post status and check for whispers from the operator.

    .DESCRIPTION
    Bidirectional communication channel for autonomous sessions.
    - Updates process registry with current status
    - Returns any new whispers addressed to this process
    - Tracks whisper index to only return new whispers

    Requires process_id to identify the process in the registry.
    #>

    param(
        [hashtable]$Arguments
    )

    $sessionId = $Arguments['session_id']
    $processId = $Arguments['process_id']
    $status = $Arguments['status']
    $nextAction = $Arguments['next_action']

    if (-not $sessionId) {
        return @{
            success = $false
            error = "session_id is required"
        }
    }

    if (-not $processId) {
        return @{
            success = $false
            error = "process_id is required"
        }
    }

    if (-not $status) {
        return @{
            success = $false
            error = "status is required"
        }
    }

    $controlDir = Join-Path $global:DotbotProjectRoot ".bot\.control"
    $controlDir = [System.IO.Path]::GetFullPath($controlDir)

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

    $processesDir = Join-Path $controlDir "processes"
    $processFile = Join-Path $processesDir "$processId.json"
    $whisperFile = Join-Path $processesDir "$processId.whisper.jsonl"

    if (-not (Test-Path $processFile)) {
        return @{
            success = $false
            error = "Process file not found: $processId"
        }
    }

    # Read existing process data
    $lastWhisperIndex = 0
    try {
        $processData = Get-Content $processFile -Raw -ErrorAction Stop | ConvertFrom-Json
        if ($null -ne $processData.last_whisper_index) {
            $lastWhisperIndex = $processData.last_whisper_index
        }
    } catch {
        return @{
            success = $false
            error = "Failed to read process file: $_"
        }
    }

    # Read whispers for this process
    $whispers = @()
    $currentIndex = 0

    if (Test-Path $whisperFile) {
        try {
            $lines = Get-Content -Path $whisperFile -Encoding utf8 -ErrorAction Stop
            foreach ($line in $lines) {
                if ($line.Trim()) {
                    $currentIndex++
                    if ($currentIndex -gt $lastWhisperIndex) {
                        try {
                            $w = $line | ConvertFrom-Json -ErrorAction Stop
                            $whispers += @{
                                instruction = $w.instruction
                                priority = $w.priority
                                timestamp = $w.timestamp
                            }
                        } catch {
                            # Skip malformed whisper lines
                        }
                    }
                }
            }
        } catch {
            # Whisper file doesn't exist or is empty - that's fine
        }
    }

    # Update process file with heartbeat info (atomic write)
    $sanitizedStatus = ConvertTo-SanitizedConsoleText $status
    $sanitizedNextAction = ConvertTo-SanitizedConsoleText $nextAction

    $processData.last_heartbeat = (Get-Date).ToUniversalTime().ToString("o")
    $processData.last_whisper_index = $currentIndex
    $processData.heartbeat_status = $sanitizedStatus
    $processData.heartbeat_next_action = $sanitizedNextAction

    try {
        $tempFile = "$processFile.tmp"
        $processData | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding utf8NoBOM -NoNewline
        Move-Item -Path $tempFile -Destination $processFile -Force
    } catch {
        return @{
            success = $false
            error = "Failed to write process file: $_"
        }
    }

    return @{
        success = $true
        process_id = $processId
        whispers = $whispers
        whisper_count = $whispers.Count
    }
}