From 3494e3e95d3697644bbc25d2ac636da8741a3f09 Mon Sep 17 00:00:00 2001 From: andrejoe630 Date: Wed, 8 Jul 2026 12:59:07 -0700 Subject: [PATCH 1/4] Add Windows PowerShell hook commands --- README.md | 9 +- plugins/orchestration/hooks/hooks.json | 15 +- .../orchestration/scripts/drain-mailbox.ps1 | 34 ++ .../orchestration/scripts/on-session-end.ps1 | 11 + .../scripts/on-session-start.ps1 | 12 + plugins/orchestration/scripts/on-stop.ps1 | 11 + .../scripts/oz-parent-common.ps1 | 546 ++++++++++++++++++ .../scripts/oz-parent-listener.ps1 | 49 ++ plugins/warp/hooks/hooks.json | 15 +- plugins/warp/scripts/common.ps1 | 144 +++++ .../warp/scripts/on-permission-request.ps1 | 40 ++ plugins/warp/scripts/on-post-tool-use.ps1 | 18 + plugins/warp/scripts/on-prompt-submit.ps1 | 19 + plugins/warp/scripts/on-session-start.ps1 | 16 + plugins/warp/scripts/on-stop.ps1 | 21 + tests/test-hooks.sh | 22 + 16 files changed, 968 insertions(+), 14 deletions(-) create mode 100644 plugins/orchestration/scripts/drain-mailbox.ps1 create mode 100644 plugins/orchestration/scripts/on-session-end.ps1 create mode 100644 plugins/orchestration/scripts/on-session-start.ps1 create mode 100644 plugins/orchestration/scripts/on-stop.ps1 create mode 100644 plugins/orchestration/scripts/oz-parent-common.ps1 create mode 100644 plugins/orchestration/scripts/oz-parent-listener.ps1 create mode 100644 plugins/warp/scripts/common.ps1 create mode 100644 plugins/warp/scripts/on-permission-request.ps1 create mode 100644 plugins/warp/scripts/on-post-tool-use.ps1 create mode 100644 plugins/warp/scripts/on-prompt-submit.ps1 create mode 100644 plugins/warp/scripts/on-session-start.ps1 create mode 100644 plugins/warp/scripts/on-stop.ps1 diff --git a/README.md b/README.md index 2b3ea66..17be8df 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ This repo is a native Codex plugin marketplace for local/dev and Oz cloud-agent .github/workflows/test.yml GitHub Actions shell test workflow plugins/warp/.codex-plugin/plugin.json Warp notification plugin manifest plugins/warp/hooks/hooks.json Warp notification hook config -plugins/warp/scripts/ Warp notification hook scripts only +plugins/warp/scripts/ Warp notification hook scripts for POSIX shells and Windows PowerShell plugins/orchestration/.codex-plugin/plugin.json plugins/orchestration/hooks/hooks.json -plugins/orchestration/scripts/ Oz parent-message listener, drain, and lifecycle scripts +plugins/orchestration/scripts/ Oz parent-message listener, drain, and lifecycle scripts for POSIX shells and Windows PowerShell plugins/orchestration/skills/ Oz orchestration skills tests/test-hooks.sh Shell tests ``` ## Plugins - `warp`: `SessionStart`, `Stop`, `PermissionRequest`, `UserPromptSubmit`, `PostToolUse` notifications for Warp. - `orchestration`: `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, `SessionEnd` parent-message delivery for Codex child runs, plus Oz skills. -Hook commands use `${PLUGIN_ROOT}/scripts/...`. +Hook commands use `${PLUGIN_ROOT}/scripts/...`; Windows overrides use Codex's `commandWindows` hook field to run `.ps1` entrypoints. ## Local install ```sh codex plugin marketplace add . @@ -37,6 +37,7 @@ Current plugin version: `0.4.0`. Keep it in sync with Warp's Codex plugin manager minimum version. ## Requirements - Codex CLI with plugin support -- `jq` +- `jq` for POSIX shell hooks +- Windows PowerShell for Windows hook overrides ## License MIT — see [LICENSE](LICENSE). diff --git a/plugins/orchestration/hooks/hooks.json b/plugins/orchestration/hooks/hooks.json index 165b7cf..0f3e923 100644 --- a/plugins/orchestration/hooks/hooks.json +++ b/plugins/orchestration/hooks/hooks.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-session-start.sh" + "command": "${PLUGIN_ROOT}/scripts/on-session-start.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-session-start.ps1\"" } ] } @@ -15,7 +16,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/drain-mailbox.sh UserPromptSubmit" + "command": "${PLUGIN_ROOT}/scripts/drain-mailbox.sh UserPromptSubmit", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/drain-mailbox.ps1\" UserPromptSubmit" } ] } @@ -26,7 +28,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/drain-mailbox.sh PostToolUse" + "command": "${PLUGIN_ROOT}/scripts/drain-mailbox.sh PostToolUse", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/drain-mailbox.ps1\" PostToolUse" } ] } @@ -36,7 +39,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-stop.sh" + "command": "${PLUGIN_ROOT}/scripts/on-stop.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-stop.ps1\"" } ] } @@ -46,7 +50,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-session-end.sh" + "command": "${PLUGIN_ROOT}/scripts/on-session-end.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-session-end.ps1\"" } ] } diff --git a/plugins/orchestration/scripts/drain-mailbox.ps1 b/plugins/orchestration/scripts/drain-mailbox.ps1 new file mode 100644 index 0000000..33d4c79 --- /dev/null +++ b/plugins/orchestration/scripts/drain-mailbox.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)] + [string]$HookEvent +) + +. "$PSScriptRoot\oz-parent-common.ps1" + +$state = Import-HookState +if ($null -eq $state) { + exit 0 +} + +if (Test-ListenerLifecycleManagedExternally) { + $output = Write-DriverHookAdditionalContext $HookEvent $state.StateDir + if ($null -ne $output) { + $output + Confirm-DriverHookOutput $state.StateDir + } + exit 0 +} + +$maxContextChars = 6000 +$parsedMaxContextChars = 0 +if ([int]::TryParse($env:OZ_PARENT_MAX_CONTEXT_CHARS, [ref]$parsedMaxContextChars) -and $parsedMaxContextChars -gt 0) { + $maxContextChars = $parsedMaxContextChars +} + +$parentContext = New-ParentContextFromStagedMessages $state.StateDir $maxContextChars +if ($null -eq $parentContext) { + exit 0 +} + +Confirm-AndRemoveStagedMessages $state.StateDir $parentContext.SurfacedIds +Write-HookAdditionalContext $HookEvent $parentContext.Context diff --git a/plugins/orchestration/scripts/on-session-end.ps1 b/plugins/orchestration/scripts/on-session-end.ps1 new file mode 100644 index 0000000..175bdc6 --- /dev/null +++ b/plugins/orchestration/scripts/on-session-end.ps1 @@ -0,0 +1,11 @@ +. "$PSScriptRoot\oz-parent-common.ps1" + +$state = New-HookState +if ($null -eq $state) { + exit 0 +} +if (Test-ListenerLifecycleManagedExternally) { + exit 0 +} + +Clear-HookState $state.StateDir diff --git a/plugins/orchestration/scripts/on-session-start.ps1 b/plugins/orchestration/scripts/on-session-start.ps1 new file mode 100644 index 0000000..19f31d7 --- /dev/null +++ b/plugins/orchestration/scripts/on-session-start.ps1 @@ -0,0 +1,12 @@ +. "$PSScriptRoot\oz-parent-common.ps1" + +$state = New-HookState +if ($null -eq $state) { + exit 0 +} +if (Test-ListenerLifecycleManagedExternally) { + exit 0 +} + +New-StateDir $state.StateDir +Start-ListenerIfNeeded (Join-Path $PSScriptRoot "oz-parent-listener.ps1") $state.StateDir diff --git a/plugins/orchestration/scripts/on-stop.ps1 b/plugins/orchestration/scripts/on-stop.ps1 new file mode 100644 index 0000000..15144cc --- /dev/null +++ b/plugins/orchestration/scripts/on-stop.ps1 @@ -0,0 +1,11 @@ +. "$PSScriptRoot\oz-parent-common.ps1" + +$state = Import-HookState +if ($null -eq $state) { + exit 0 +} + +$output = Write-StopBlockIfPending $state.StateDir +if ($null -ne $output) { + $output +} diff --git a/plugins/orchestration/scripts/oz-parent-common.ps1 b/plugins/orchestration/scripts/oz-parent-common.ps1 new file mode 100644 index 0000000..1a5282e --- /dev/null +++ b/plugins/orchestration/scripts/oz-parent-common.ps1 @@ -0,0 +1,546 @@ +$ErrorActionPreference = "Stop" + +function Read-HookInput { + return [Console]::In.ReadToEnd() +} + +function ConvertFrom-JsonSafe { + param([string]$Json) + + if ([string]::IsNullOrWhiteSpace($Json)) { + return $null + } + + try { + return $Json | ConvertFrom-Json + } catch { + return $null + } +} + +function Get-JsonProperty { + param( + [object]$Object, + [string]$Name, + [object]$Default = "" + ) + + if ($null -eq $Object) { + return $Default + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { + return $Default + } + + return $property.Value +} + +function Test-OzHarnessAvailable { + if ([string]::IsNullOrEmpty($env:OZ_CLI)) { + return $false + } + if ([string]::IsNullOrEmpty($env:OZ_RUN_ID)) { + return $false + } + if ([string]::IsNullOrEmpty($env:OZ_PARENT_RUN_ID)) { + return $false + } + + return $null -ne (Get-Command $env:OZ_CLI -ErrorAction SilentlyContinue) +} + +function Get-StateRoot { + if (-not [string]::IsNullOrEmpty($env:OZ_PARENT_STATE_ROOT)) { + return $env:OZ_PARENT_STATE_ROOT + } + + return (Join-Path $HOME ".codex\oz-parent-bridge") +} + +function Get-StateDirFromSessionId { + param([string]$SessionId) + + return (Join-Path (Get-StateRoot) $SessionId) +} + +function Get-StateDirFromInput { + param([string]$InputJson) + + $inputObject = ConvertFrom-JsonSafe $InputJson + $sessionId = [string](Get-JsonProperty $inputObject "session_id" "") + if ([string]::IsNullOrEmpty($sessionId)) { + return $null + } + + return (Get-StateDirFromSessionId $sessionId) +} + +function New-HookState { + $inputJson = Read-HookInput + if (-not (Test-OzHarnessAvailable)) { + return $null + } + + $stateDir = Get-StateDirFromInput $inputJson + if ([string]::IsNullOrEmpty($stateDir)) { + return $null + } + + return [pscustomobject]@{ + InputJson = $inputJson + StateDir = $stateDir + } +} + +function Import-HookState { + $state = New-HookState + if ($null -eq $state) { + return $null + } + + if (Test-ListenerLifecycleManagedExternally) { + if (-not (Test-Path -LiteralPath $state.StateDir -PathType Container)) { + return $null + } + } else { + New-StateDir $state.StateDir + } + + return $state +} + +function Test-ListenerLifecycleManagedExternally { + return $env:OZ_PARENT_LISTENER_MANAGED_EXTERNALLY -eq "1" +} + +function New-StateDir { + param([string]$StateDir) + + New-Item -ItemType Directory -Force -Path (Get-StagedDir $StateDir) | Out-Null +} + +function Get-StagedDir { + param([string]$StateDir) + + return (Join-Path $StateDir "staged") +} + +function Get-SurfacedDir { + param([string]$StateDir) + + return (Join-Path $StateDir "surfaced") +} + +function Get-HookOutputFile { + param([string]$StateDir) + + return (Join-Path $StateDir "pending-hook-output.json") +} + +function Get-HookOutputAckFile { + param([string]$StateDir) + + return (Join-Path $StateDir "pending-hook-output.ack") +} + +function Get-ListenerPidFile { + param([string]$StateDir) + + return (Join-Path $StateDir "listener.pid") +} + +function Get-ListenerLogFile { + param([string]$StateDir) + + return (Join-Path $StateDir "listener.log") +} + +function Get-LastSequenceFile { + param([string]$StateDir) + + return (Join-Path $StateDir "last-sequence") +} + +function Get-StagedMessagePath { + param( + [string]$StateDir, + [int64]$Sequence, + [string]$MessageId + ) + + return (Join-Path (Get-StagedDir $StateDir) ("{0:D20}-{1}.json" -f $Sequence, $MessageId)) +} + +function Get-SortedMessageFiles { + param([string]$Directory) + + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { + return @() + } + + return @(Get-ChildItem -LiteralPath $Directory -Filter "*.json" -File | Sort-Object Name | ForEach-Object { $_.FullName }) +} + +function Get-SortedStagedMessages { + param([string]$StateDir) + + return Get-SortedMessageFiles (Get-StagedDir $StateDir) +} + +function Get-SortedSurfacedMessages { + param([string]$StateDir) + + return Get-SortedMessageFiles (Get-SurfacedDir $StateDir) +} + +function Get-StagedMessageCount { + param([string]$StateDir) + + return @(Get-SortedStagedMessages $StateDir).Count +} + +function Get-SurfacedMessageCount { + param([string]$StateDir) + + return @(Get-SortedSurfacedMessages $StateDir).Count +} + +function Test-DriverHookOutputAvailable { + param([string]$StateDir) + + return (Test-Path -LiteralPath (Get-HookOutputFile $StateDir) -PathType Leaf) -and + -not (Test-Path -LiteralPath (Get-HookOutputAckFile $StateDir) -PathType Leaf) +} + +function Wait-ForDriverHookOutput { + param([string]$StateDir) + + for ($attempt = 0; $attempt -lt 40; $attempt++) { + if (Test-DriverHookOutputAvailable $StateDir) { + return $true + } + if (Test-Path -LiteralPath (Get-HookOutputAckFile $StateDir) -PathType Leaf) { + return $false + } + Start-Sleep -Milliseconds 50 + } + + return (Test-DriverHookOutputAvailable $StateDir) +} + +function Write-HookAdditionalContext { + param( + [string]$HookEvent, + [string]$AdditionalContext + ) + + [ordered]@{ + hookSpecificOutput = [ordered]@{ + hookEventName = $HookEvent + additionalContext = $AdditionalContext + } + } | ConvertTo-Json -Compress -Depth 20 +} + +function Write-DriverHookAdditionalContext { + param( + [string]$HookEvent, + [string]$StateDir + ) + + if (-not (Wait-ForDriverHookOutput $StateDir)) { + return $null + } + + $outputObject = ConvertFrom-JsonSafe (Get-Content -Raw -LiteralPath (Get-HookOutputFile $StateDir)) + $additionalContext = [string](Get-JsonProperty $outputObject "additional_context" "") + if ([string]::IsNullOrEmpty($additionalContext)) { + return $null + } + + return (Write-HookAdditionalContext $HookEvent $additionalContext) +} + +function Confirm-DriverHookOutput { + param([string]$StateDir) + + New-Item -ItemType File -Force -Path (Get-HookOutputAckFile $StateDir) | Out-Null +} + +function Get-DriverPendingParentMessageCount { + param([string]$StateDir) + + $pendingCount = Get-StagedMessageCount $StateDir + if (-not (Test-Path -LiteralPath (Get-HookOutputAckFile $StateDir) -PathType Leaf)) { + $pendingCount += Get-SurfacedMessageCount $StateDir + } + + return $pendingCount +} + +function Get-PendingParentMessageCount { + param([string]$StateDir) + + if (Test-ListenerLifecycleManagedExternally) { + return Get-DriverPendingParentMessageCount $StateDir + } + + return Get-StagedMessageCount $StateDir +} + +function Test-ListenerRunning { + param([string]$StateDir) + + $pidFile = Get-ListenerPidFile $StateDir + if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { + return $false + } + + $listenerPid = Get-Content -Raw -LiteralPath $pidFile -ErrorAction SilentlyContinue + if ($null -eq $listenerPid) { + $listenerPid = "" + } else { + $listenerPid = $listenerPid.Trim() + } + if ([string]::IsNullOrEmpty($listenerPid)) { + return $false + } + + try { + return $null -ne (Get-Process -Id ([int]$listenerPid) -ErrorAction Stop) + } catch { + return $false + } +} + +function Stop-Listener { + param([string]$StateDir) + + $pidFile = Get-ListenerPidFile $StateDir + if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { + return + } + + $listenerPid = Get-Content -Raw -LiteralPath $pidFile -ErrorAction SilentlyContinue + if ($null -eq $listenerPid) { + $listenerPid = "" + } else { + $listenerPid = $listenerPid.Trim() + } + if (-not [string]::IsNullOrEmpty($listenerPid)) { + try { + Stop-Process -Id ([int]$listenerPid) -ErrorAction SilentlyContinue + } catch { + } + } + + Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue +} + +function Initialize-LastSequenceFile { + param([string]$StateDir) + + $path = Get-LastSequenceFile $StateDir + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + New-Item -ItemType File -Force -Path $path | Out-Null + } +} + +function Start-ListenerIfNeeded { + param( + [string]$ListenerScript, + [string]$StateDir + ) + + if (Test-ListenerRunning $StateDir) { + return + } + + New-StateDir $StateDir + Stop-Listener $StateDir + + $arguments = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $ListenerScript, + $StateDir + ) + $process = Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -WindowStyle Hidden -PassThru + Set-Content -LiteralPath (Get-ListenerPidFile $StateDir) -Value $process.Id -Encoding ASCII +} + +function Clear-HookState { + param([string]$StateDir) + + Stop-Listener $StateDir + Remove-Item -LiteralPath $StateDir -Recurse -Force -ErrorAction SilentlyContinue +} + +function Get-StopLingerAttempts { + $attempts = 240 + $parsed = 0 + if ([int]::TryParse($env:OZ_PARENT_STOP_LINGER_ATTEMPTS, [ref]$parsed) -and $parsed -ge 0) { + $attempts = $parsed + } + return $attempts +} + +function Get-StopLingerPollMilliseconds { + $pollSeconds = 0.25 + $parsed = 0.0 + if ([double]::TryParse($env:OZ_PARENT_STOP_LINGER_POLL_SECONDS, [ref]$parsed) -and $parsed -ge 0) { + $pollSeconds = $parsed + } + + return [int]($pollSeconds * 1000) +} + +function Wait-ForPendingParentMessages { + param([string]$StateDir) + + $attempts = Get-StopLingerAttempts + $pollMilliseconds = Get-StopLingerPollMilliseconds + + for ($attempt = 0; $attempt -le $attempts; $attempt++) { + $pendingCount = Get-PendingParentMessageCount $StateDir + if ($pendingCount -gt 0) { + return $pendingCount + } + + if ($attempt -lt $attempts) { + Start-Sleep -Milliseconds $pollMilliseconds + } + } + + return 0 +} + +function Write-StopBlockIfPending { + param([string]$StateDir) + + $pendingCount = Wait-ForPendingParentMessages $StateDir + if ($pendingCount -le 0) { + return $null + } + + $reason = "There are $pendingCount pending parent message(s) from the lead Oz run. Continue so the next safe boundary can surface them." + return ([ordered]@{ + decision = "block" + reason = $reason + } | ConvertTo-Json -Compress -Depth 10) +} + +function Confirm-MessageDelivered { + param([string]$MessageId) + + try { + & $env:OZ_CLI run message mark-delivered $MessageId *> $null + return + } catch { + } + + try { + & $env:OZ_CLI run message delivered $MessageId *> $null + } catch { + } +} + +function Remove-StagedMessage { + param( + [string]$StateDir, + [string]$MessageId + ) + + Get-ChildItem -LiteralPath (Get-StagedDir $StateDir) -Filter "*-$MessageId.json" -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue +} + +function New-ParentContextFromStagedMessages { + param( + [string]$StateDir, + [int]$MaxContextChars = 6000 + ) + + $renderedContext = "Lead-agent updates arrived from Oz. Treat the latest parent instructions below as authoritative.`n" + $surfacedIds = New-Object System.Collections.Generic.List[string] + $totalStaged = 0 + + foreach ($stagedFile in Get-SortedStagedMessages $StateDir) { + $totalStaged += 1 + $messageObject = ConvertFrom-JsonSafe (Get-Content -Raw -LiteralPath $stagedFile) + $messageId = [string](Get-JsonProperty $messageObject "message_id" "") + $senderRunId = [string](Get-JsonProperty $messageObject "sender_run_id" "") + $subject = [string](Get-JsonProperty $messageObject "subject" "") + $body = [string](Get-JsonProperty $messageObject "body" "") + $sequence = [string](Get-JsonProperty $messageObject "sequence" "") + + if ([string]::IsNullOrEmpty($messageId)) { + continue + } + if ([string]::IsNullOrEmpty($subject)) { + $subject = "(no subject)" + } + + $block = "---`nParent message" + if (-not [string]::IsNullOrEmpty($sequence)) { + $block = "$block #$sequence" + } + if (-not [string]::IsNullOrEmpty($senderRunId)) { + $block = "$block from $senderRunId" + } + $block = "$block`nSubject: $subject`n`n$body" + + $separator = "" + if ($surfacedIds.Count -gt 0) { + $separator = "`n`n" + } + + $candidate = "$renderedContext$separator$block" + if ($candidate.Length -gt $MaxContextChars) { + $remaining = $MaxContextChars - $renderedContext.Length - $separator.Length + if ($remaining -le 3 -and $surfacedIds.Count -gt 0) { + break + } + if ($remaining -gt 3 -and $block.Length -gt $remaining) { + $block = $block.Substring(0, $remaining - 3) + "..." + } elseif ($surfacedIds.Count -gt 0) { + break + } + } + + $renderedContext = "$renderedContext$separator$block" + $surfacedIds.Add($messageId) + } + + if ($surfacedIds.Count -eq 0) { + return $null + } + + $remainingCount = $totalStaged - $surfacedIds.Count + if ($remainingCount -gt 0) { + $note = "`n`nMore parent messages are still staged and will be surfaced on a later turn." + if (($renderedContext.Length + $note.Length) -le $MaxContextChars) { + $renderedContext = "$renderedContext$note" + } + } + + return [pscustomobject]@{ + Context = $renderedContext + SurfacedIds = @($surfacedIds.ToArray()) + RemainingCount = $remainingCount + } +} + +function Confirm-AndRemoveStagedMessages { + param( + [string]$StateDir, + [string[]]$MessageIds + ) + + foreach ($messageId in $MessageIds) { + Confirm-MessageDelivered $messageId + Remove-StagedMessage $StateDir $messageId + } +} diff --git a/plugins/orchestration/scripts/oz-parent-listener.ps1 b/plugins/orchestration/scripts/oz-parent-listener.ps1 new file mode 100644 index 0000000..bbd7590 --- /dev/null +++ b/plugins/orchestration/scripts/oz-parent-listener.ps1 @@ -0,0 +1,49 @@ +param( + [Parameter(Mandatory = $true)] + [string]$StateDir +) + +. "$PSScriptRoot\oz-parent-common.ps1" + +function Add-MessageFromWatchRecord { + param( + [string]$StateDir, + [string]$Line + ) + + $messageObject = ConvertFrom-JsonSafe $Line + $sequenceValue = Get-JsonProperty $messageObject "sequence" $null + $messageId = [string](Get-JsonProperty $messageObject "message_id" "") + + if ($null -eq $sequenceValue -or [string]::IsNullOrEmpty($messageId)) { + return + } + + $sequence = [int64]$sequenceValue + $target = Get-StagedMessagePath $StateDir $sequence $messageId + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + Set-Content -LiteralPath $target -Value $Line -Encoding UTF8 + } + + Set-Content -LiteralPath (Get-LastSequenceFile $StateDir) -Value $sequence -Encoding ASCII +} + +New-StateDir $StateDir +Initialize-LastSequenceFile $StateDir + +$lastSequence = Get-Content -Raw -LiteralPath (Get-LastSequenceFile $StateDir) -ErrorAction SilentlyContinue +if ($null -eq $lastSequence) { + $lastSequence = "" +} else { + $lastSequence = $lastSequence.Trim() +} +if ([string]::IsNullOrEmpty($lastSequence)) { + $lastSequence = "0" +} + +& $env:OZ_CLI run message watch $env:OZ_RUN_ID --since-sequence $lastSequence --output-format ndjson | + ForEach-Object { + if (-not [string]::IsNullOrEmpty($_)) { + Add-MessageFromWatchRecord $StateDir $_ + } + } diff --git a/plugins/warp/hooks/hooks.json b/plugins/warp/hooks/hooks.json index ce26297..0b8c7d2 100644 --- a/plugins/warp/hooks/hooks.json +++ b/plugins/warp/hooks/hooks.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-session-start.sh" + "command": "${PLUGIN_ROOT}/scripts/on-session-start.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-session-start.ps1\"" } ] } @@ -15,7 +16,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-stop.sh" + "command": "${PLUGIN_ROOT}/scripts/on-stop.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-stop.ps1\"" } ] } @@ -25,7 +27,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-permission-request.sh" + "command": "${PLUGIN_ROOT}/scripts/on-permission-request.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-permission-request.ps1\"" } ] } @@ -35,7 +38,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-prompt-submit.sh" + "command": "${PLUGIN_ROOT}/scripts/on-prompt-submit.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-prompt-submit.ps1\"" } ] } @@ -45,7 +49,8 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on-post-tool-use.sh" + "command": "${PLUGIN_ROOT}/scripts/on-post-tool-use.sh", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/scripts/on-post-tool-use.ps1\"" } ] } diff --git a/plugins/warp/scripts/common.ps1 b/plugins/warp/scripts/common.ps1 new file mode 100644 index 0000000..654bf4b --- /dev/null +++ b/plugins/warp/scripts/common.ps1 @@ -0,0 +1,144 @@ +$ErrorActionPreference = "Stop" + +$PluginCurrentProtocolVersion = 1 + +function Test-ShouldUseStructured { + if ([string]::IsNullOrEmpty($env:WARP_CLI_AGENT_PROTOCOL_VERSION)) { + return $false + } + if ([string]::IsNullOrEmpty($env:WARP_CLIENT_VERSION)) { + return $false + } + return $true +} + +function Read-HookInput { + return [Console]::In.ReadToEnd() +} + +function ConvertFrom-JsonSafe { + param([string]$Json) + + if ([string]::IsNullOrWhiteSpace($Json)) { + return $null + } + + try { + return $Json | ConvertFrom-Json + } catch { + return $null + } +} + +function Get-JsonProperty { + param( + [object]$Object, + [string]$Name, + [object]$Default = "" + ) + + if ($null -eq $Object) { + return $Default + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { + return $Default + } + + return $property.Value +} + +function Get-TruncatedText { + param( + [string]$Value, + [int]$MaxLength + ) + + if ($null -eq $Value) { + return "" + } + + if ($Value.Length -gt $MaxLength) { + return $Value.Substring(0, $MaxLength - 3) + "..." + } + + return $Value +} + +function Get-ProtocolVersion { + $warpVersion = 1 + $parsed = 0 + if ([int]::TryParse($env:WARP_CLI_AGENT_PROTOCOL_VERSION, [ref]$parsed)) { + $warpVersion = $parsed + } + + if ($warpVersion -lt $PluginCurrentProtocolVersion) { + return $warpVersion + } + + return $PluginCurrentProtocolVersion +} + +function New-WarpPayload { + param( + [string]$InputJson, + [string]$Event, + [hashtable]$ExtraFields = @{} + ) + + $inputObject = ConvertFrom-JsonSafe $InputJson + $sessionId = [string](Get-JsonProperty $inputObject "session_id" "") + $cwd = [string](Get-JsonProperty $inputObject "cwd" "") + $project = "" + + if (-not [string]::IsNullOrEmpty($cwd)) { + $project = Split-Path -Leaf $cwd + } + + $payload = [ordered]@{ + v = Get-ProtocolVersion + agent = "codex" + event = $Event + session_id = $sessionId + cwd = $cwd + project = $project + } + + foreach ($key in $ExtraFields.Keys) { + $payload[$key] = $ExtraFields[$key] + } + + return ($payload | ConvertTo-Json -Compress -Depth 50) +} + +function Send-WarpNotification { + param( + [string]$Title, + [string]$Body + ) + + $escape = [char]27 + $bell = [char]7 + $message = "$escape]777;notify;$Title;$Body$bell" + + try { + $stream = [System.IO.File]::Open("CONOUT$", [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Write) + try { + $writer = New-Object System.IO.StreamWriter($stream, [Console]::OutputEncoding) + $writer.AutoFlush = $true + $writer.Write($message) + } finally { + if ($writer) { + $writer.Dispose() + } else { + $stream.Dispose() + } + } + } catch { + try { + [Console]::Out.Write($message) + } catch { + } + } +} diff --git a/plugins/warp/scripts/on-permission-request.ps1 b/plugins/warp/scripts/on-permission-request.ps1 new file mode 100644 index 0000000..e25ada9 --- /dev/null +++ b/plugins/warp/scripts/on-permission-request.ps1 @@ -0,0 +1,40 @@ +# Hook script for Codex PermissionRequest event. +# Sends a structured Warp notification when Codex needs permission to run a tool. + +. "$PSScriptRoot\common.ps1" + +if (-not (Test-ShouldUseStructured)) { + exit 0 +} + +$inputJson = Read-HookInput +$inputObject = ConvertFrom-JsonSafe $inputJson +$toolName = [string](Get-JsonProperty $inputObject "tool_name" "unknown") +$toolInput = Get-JsonProperty $inputObject "tool_input" $null +if ($null -eq $toolInput) { + $toolInput = [pscustomobject]@{} +} + +$toolPreview = "" +$commandProperty = $toolInput.PSObject.Properties["command"] +$filePathProperty = $toolInput.PSObject.Properties["file_path"] +if ($null -ne $commandProperty -and $null -ne $commandProperty.Value) { + $toolPreview = [string]$commandProperty.Value +} elseif ($null -ne $filePathProperty -and $null -ne $filePathProperty.Value) { + $toolPreview = [string]$filePathProperty.Value +} else { + $toolPreview = Get-TruncatedText ($toolInput | ConvertTo-Json -Compress -Depth 50) 80 +} + +$summary = "Wants to run $toolName" +if (-not [string]::IsNullOrEmpty($toolPreview)) { + $summary = "${summary}: $(Get-TruncatedText $toolPreview 120)" +} + +$body = New-WarpPayload $inputJson "permission_request" @{ + summary = $summary + tool_name = $toolName + tool_input = $toolInput +} + +Send-WarpNotification "warp://cli-agent" $body diff --git a/plugins/warp/scripts/on-post-tool-use.ps1 b/plugins/warp/scripts/on-post-tool-use.ps1 new file mode 100644 index 0000000..1e5490f --- /dev/null +++ b/plugins/warp/scripts/on-post-tool-use.ps1 @@ -0,0 +1,18 @@ +# Hook script for Codex PostToolUse event. +# Sends a structured Warp notification after a tool call completes. + +. "$PSScriptRoot\common.ps1" + +if (-not (Test-ShouldUseStructured)) { + exit 0 +} + +$inputJson = Read-HookInput +$inputObject = ConvertFrom-JsonSafe $inputJson +$toolName = [string](Get-JsonProperty $inputObject "tool_name" "") + +$body = New-WarpPayload $inputJson "tool_complete" @{ + tool_name = $toolName +} + +Send-WarpNotification "warp://cli-agent" $body diff --git a/plugins/warp/scripts/on-prompt-submit.ps1 b/plugins/warp/scripts/on-prompt-submit.ps1 new file mode 100644 index 0000000..9558917 --- /dev/null +++ b/plugins/warp/scripts/on-prompt-submit.ps1 @@ -0,0 +1,19 @@ +# Hook script for Codex UserPromptSubmit event. +# Sends a structured Warp notification when the user submits a prompt. + +. "$PSScriptRoot\common.ps1" + +if (-not (Test-ShouldUseStructured)) { + exit 0 +} + +$inputJson = Read-HookInput +$inputObject = ConvertFrom-JsonSafe $inputJson +$query = [string](Get-JsonProperty $inputObject "prompt" "") +$query = Get-TruncatedText $query 200 + +$body = New-WarpPayload $inputJson "prompt_submit" @{ + query = $query +} + +Send-WarpNotification "warp://cli-agent" $body diff --git a/plugins/warp/scripts/on-session-start.ps1 b/plugins/warp/scripts/on-session-start.ps1 new file mode 100644 index 0000000..be51c6d --- /dev/null +++ b/plugins/warp/scripts/on-session-start.ps1 @@ -0,0 +1,16 @@ +# Hook script for Codex SessionStart event. +# Emits plugin version via warp://cli-agent so Warp can track the session. + +. "$PSScriptRoot\common.ps1" + +if (-not (Test-ShouldUseStructured)) { + exit 0 +} + +$pluginVersion = "0.4.0" +$inputJson = Read-HookInput +$body = New-WarpPayload $inputJson "session_start" @{ + plugin_version = $pluginVersion +} + +Send-WarpNotification "warp://cli-agent" $body diff --git a/plugins/warp/scripts/on-stop.ps1 b/plugins/warp/scripts/on-stop.ps1 new file mode 100644 index 0000000..8d67c43 --- /dev/null +++ b/plugins/warp/scripts/on-stop.ps1 @@ -0,0 +1,21 @@ +# Hook script for Codex Stop event. +# Sends a structured Warp notification when Codex finishes a turn. + +. "$PSScriptRoot\common.ps1" + +if (-not (Test-ShouldUseStructured)) { + exit 0 +} + +$inputJson = Read-HookInput +$inputObject = ConvertFrom-JsonSafe $inputJson +$response = [string](Get-JsonProperty $inputObject "last_assistant_message" "") +$response = Get-TruncatedText $response 200 +$transcriptPath = [string](Get-JsonProperty $inputObject "transcript_path" "") + +$body = New-WarpPayload $inputJson "stop" @{ + response = $response + transcript_path = $transcriptPath +} + +Send-WarpNotification "warp://cli-agent" $body diff --git a/tests/test-hooks.sh b/tests/test-hooks.sh index e54738c..aa252cd 100755 --- a/tests/test-hooks.sh +++ b/tests/test-hooks.sh @@ -224,6 +224,18 @@ assert_file_exists "warp plugin manifest exists" "$REPO_ROOT/plugins/warp/.codex assert_file_exists "oz plugin manifest exists" "$REPO_ROOT/plugins/orchestration/.codex-plugin/plugin.json" assert_file_exists "warp hook config exists" "$REPO_ROOT/plugins/warp/hooks/hooks.json" assert_file_exists "oz hook config exists" "$REPO_ROOT/plugins/orchestration/hooks/hooks.json" +assert_file_exists "warp powershell common exists" "$REPO_ROOT/plugins/warp/scripts/common.ps1" +assert_file_exists "warp powershell session start hook exists" "$REPO_ROOT/plugins/warp/scripts/on-session-start.ps1" +assert_file_exists "warp powershell stop hook exists" "$REPO_ROOT/plugins/warp/scripts/on-stop.ps1" +assert_file_exists "warp powershell permission request hook exists" "$REPO_ROOT/plugins/warp/scripts/on-permission-request.ps1" +assert_file_exists "warp powershell prompt submit hook exists" "$REPO_ROOT/plugins/warp/scripts/on-prompt-submit.ps1" +assert_file_exists "warp powershell post tool use hook exists" "$REPO_ROOT/plugins/warp/scripts/on-post-tool-use.ps1" +assert_file_exists "oz powershell common exists" "$REPO_ROOT/plugins/orchestration/scripts/oz-parent-common.ps1" +assert_file_exists "oz powershell listener exists" "$REPO_ROOT/plugins/orchestration/scripts/oz-parent-listener.ps1" +assert_file_exists "oz powershell session start hook exists" "$REPO_ROOT/plugins/orchestration/scripts/on-session-start.ps1" +assert_file_exists "oz powershell drain hook exists" "$REPO_ROOT/plugins/orchestration/scripts/drain-mailbox.ps1" +assert_file_exists "oz powershell stop hook exists" "$REPO_ROOT/plugins/orchestration/scripts/on-stop.ps1" +assert_file_exists "oz powershell session end hook exists" "$REPO_ROOT/plugins/orchestration/scripts/on-session-end.ps1" assert_file_exists "github workflow exists" "$REPO_ROOT/.github/workflows/test.yml" assert_json_field "marketplace name" "$(cat "$REPO_ROOT/.agents/plugins/marketplace.json")" ".name" "codex-warp" assert_json_field "warp plugin name" "$(cat "$REPO_ROOT/plugins/warp/.codex-plugin/plugin.json")" ".name" "warp" @@ -231,8 +243,18 @@ assert_json_field "oz plugin name" "$(cat "$REPO_ROOT/plugins/orchestration/.cod assert_contains "warp hooks use PLUGIN_ROOT" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" '${PLUGIN_ROOT}/scripts/on-session-start.sh' assert_contains "warp hooks include prompt submit" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" '${PLUGIN_ROOT}/scripts/on-prompt-submit.sh' assert_contains "warp hooks include post tool use" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" '${PLUGIN_ROOT}/scripts/on-post-tool-use.sh' +assert_json_field "warp session start uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" ".hooks.SessionStart[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-session-start.ps1"' +assert_json_field "warp stop uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" ".hooks.Stop[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-stop.ps1"' +assert_json_field "warp permission request uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" ".hooks.PermissionRequest[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-permission-request.ps1"' +assert_json_field "warp prompt submit uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" ".hooks.UserPromptSubmit[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-prompt-submit.ps1"' +assert_json_field "warp post tool use uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/warp/hooks/hooks.json")" ".hooks.PostToolUse[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-post-tool-use.ps1"' assert_contains "oz hooks use PLUGIN_ROOT" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" '${PLUGIN_ROOT}/scripts/drain-mailbox.sh UserPromptSubmit' assert_contains "oz hooks include session end" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" '${PLUGIN_ROOT}/scripts/on-session-end.sh' +assert_json_field "oz session start uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" ".hooks.SessionStart[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-session-start.ps1"' +assert_json_field "oz prompt submit uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" ".hooks.UserPromptSubmit[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/drain-mailbox.ps1" UserPromptSubmit' +assert_json_field "oz post tool use uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" ".hooks.PostToolUse[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/drain-mailbox.ps1" PostToolUse' +assert_json_field "oz stop uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" ".hooks.Stop[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-stop.ps1"' +assert_json_field "oz session end uses Windows PowerShell" "$(cat "$REPO_ROOT/plugins/orchestration/hooks/hooks.json")" ".hooks.SessionEnd[0].hooks[0].commandWindows" 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}/scripts/on-session-end.ps1"' echo "" From 5c01e7c7e2d463aa8d0f9bc66d09e222195952b6 Mon Sep 17 00:00:00 2001 From: andrejoe630 Date: Wed, 8 Jul 2026 13:59:09 -0700 Subject: [PATCH 2/4] Avoid stdout fallback for Windows notifications --- plugins/warp/scripts/common.ps1 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/warp/scripts/common.ps1 b/plugins/warp/scripts/common.ps1 index 654bf4b..6c91da7 100644 --- a/plugins/warp/scripts/common.ps1 +++ b/plugins/warp/scripts/common.ps1 @@ -136,9 +136,8 @@ function Send-WarpNotification { } } } catch { - try { - [Console]::Out.Write($message) - } catch { - } + # Hook stdout is reserved for Codex hook control JSON. If there is no + # attached console device, drop the notification rather than emitting + # OSC text to captured stdout and making the hook fail. } } From 60bc42226f600f92981d14b25dbd9216c9707618 Mon Sep 17 00:00:00 2001 From: andrejoe630 Date: Wed, 15 Jul 2026 11:42:06 -0700 Subject: [PATCH 3/4] fix Windows PowerShell hook startup and CI --- .github/workflows/test.yml | 8 + .../scripts/oz-parent-common.ps1 | 10 +- tests/test-hooks.ps1 | 211 ++++++++++++++++++ 3 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 tests/test-hooks.ps1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36cef28..29fbb0e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,3 +18,11 @@ jobs: bash "$f" || failed=1 done exit $failed + + windows-plugin-tests: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Run PowerShell tests + shell: powershell + run: .\tests\test-hooks.ps1 diff --git a/plugins/orchestration/scripts/oz-parent-common.ps1 b/plugins/orchestration/scripts/oz-parent-common.ps1 index 1a5282e..fc0de05 100644 --- a/plugins/orchestration/scripts/oz-parent-common.ps1 +++ b/plugins/orchestration/scripts/oz-parent-common.ps1 @@ -361,12 +361,10 @@ function Start-ListenerIfNeeded { New-StateDir $StateDir Stop-Listener $StateDir - $arguments = @( - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", $ListenerScript, - $StateDir - ) + # Start-Process joins ArgumentList arrays into a single command line. Quote + # both paths explicitly so listener startup also works from paths containing + # spaces, such as the default Windows user and plugin directories. + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" "{1}"' -f $ListenerScript, $StateDir $process = Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -WindowStyle Hidden -PassThru Set-Content -LiteralPath (Get-ListenerPidFile $StateDir) -Value $process.Id -Encoding ASCII } diff --git a/tests/test-hooks.ps1 b/tests/test-hooks.ps1 new file mode 100644 index 0000000..02dee3f --- /dev/null +++ b/tests/test-hooks.ps1 @@ -0,0 +1,211 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Assert-Equal { + param( + $Expected, + $Actual, + [string]$Message + ) + + if ($Expected -ne $Actual) { + throw "$Message Expected '$Expected', got '$Actual'." + } +} + +function Assert-True { + param( + [bool]$Condition, + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Invoke-PowerShellHook { + param( + [string]$ScriptPath, + [string]$InputJson, + [string[]]$ScriptArguments = @() + ) + + $powershellArguments = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $ScriptPath + ) + $ScriptArguments + + $output = @($InputJson | & powershell.exe @powershellArguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$ScriptPath exited with code $LASTEXITCODE.`n$($output -join "`n")" + } + + return $output +} + +function Test-PowerShellSyntax { + $parseFailures = New-Object System.Collections.Generic.List[string] + + foreach ($script in Get-ChildItem -LiteralPath $repoRoot -Recurse -Filter "*.ps1") { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $script.FullName, + [ref]$tokens, + [ref]$errors + ) + + foreach ($parseError in $errors) { + [void]$parseFailures.Add("$($script.FullName): $($parseError.Message)") + } + } + + if ($parseFailures.Count -gt 0) { + throw "PowerShell parse failures:`n$($parseFailures -join "`n")" + } +} + +function Test-WindowsHookCommands { + $manifestPaths = @( + (Join-Path $repoRoot "plugins\warp\hooks\hooks.json"), + (Join-Path $repoRoot "plugins\orchestration\hooks\hooks.json") + ) + + foreach ($manifestPath in $manifestPaths) { + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + $pluginRoot = Split-Path (Split-Path $manifestPath -Parent) -Parent + + foreach ($event in $manifest.hooks.PSObject.Properties) { + foreach ($group in $event.Value) { + foreach ($hook in $group.hooks) { + $commandWindows = [string]$hook.commandWindows + Assert-True (-not [string]::IsNullOrWhiteSpace($commandWindows)) "$manifestPath $($event.Name) is missing commandWindows." + + if ($commandWindows -notmatch '-File\s+"?\$\{PLUGIN_ROOT\}/(?[^"\s]+\.ps1)"?') { + throw "$manifestPath $($event.Name) has an invalid PowerShell commandWindows value: $commandWindows" + } + + $scriptPath = Join-Path $pluginRoot ($Matches.scriptPath -replace "/", "\") + Assert-True (Test-Path -LiteralPath $scriptPath -PathType Leaf) "$manifestPath references missing script $scriptPath." + } + } + } + } +} + +function Test-WarpPayload { + . (Join-Path $repoRoot "plugins\warp\scripts\common.ps1") + + $payload = New-WarpPayload ` + '{"session_id":"session-1","cwd":"C:\\work\\project"}' ` + "test-event" ` + @{ detail = "ok" } | + ConvertFrom-Json + + Assert-Equal "codex" $payload.agent "Warp payload agent mismatch." + Assert-Equal "test-event" $payload.event "Warp payload event mismatch." + Assert-Equal "session-1" $payload.session_id "Warp payload session mismatch." + Assert-Equal "project" $payload.project "Warp payload project mismatch." + Assert-Equal "ok" $payload.detail "Warp payload extra field mismatch." +} + +function Test-WarpHookEntryPoints { + $oldProtocolVersion = $env:WARP_CLI_AGENT_PROTOCOL_VERSION + $oldClientVersion = $env:WARP_CLIENT_VERSION + + try { + $env:WARP_CLI_AGENT_PROTOCOL_VERSION = "1" + $env:WARP_CLIENT_VERSION = "test" + $inputJson = '{"session_id":"session-1","cwd":"C:\\work\\project","prompt":"Test prompt","tool_name":"shell","tool_input":{"command":"echo test"},"last_assistant_message":"Done","transcript_path":"C:\\work\\transcript.jsonl"}' + + foreach ($script in Get-ChildItem -LiteralPath (Join-Path $repoRoot "plugins\warp\scripts") -Filter "on-*.ps1") { + [void](Invoke-PowerShellHook $script.FullName $inputJson) + } + } finally { + $env:WARP_CLI_AGENT_PROTOCOL_VERSION = $oldProtocolVersion + $env:WARP_CLIENT_VERSION = $oldClientVersion + } +} + +function Test-ListenerLaunchWithSpaces { + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "codex warp powershell tests $([guid]::NewGuid())" + $listenerDir = Join-Path $tempRoot "listener scripts" + $stateRoot = Join-Path $tempRoot "listener state" + $stateDir = Join-Path $stateRoot "session-1" + $fakeCli = Join-Path $tempRoot "fake oz cli.ps1" + $oldOzCli = $env:OZ_CLI + $oldOzRunId = $env:OZ_RUN_ID + $oldOzParentRunId = $env:OZ_PARENT_RUN_ID + $oldStateRoot = $env:OZ_PARENT_STATE_ROOT + $oldManagedExternally = $env:OZ_PARENT_LISTENER_MANAGED_EXTERNALLY + + try { + New-Item -ItemType Directory -Force -Path $listenerDir | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot "plugins\orchestration\scripts\oz-parent-common.ps1") -Destination $listenerDir + Copy-Item -LiteralPath (Join-Path $repoRoot "plugins\orchestration\scripts\oz-parent-listener.ps1") -Destination $listenerDir + Copy-Item -LiteralPath (Join-Path $repoRoot "plugins\orchestration\scripts\on-session-start.ps1") -Destination $listenerDir + Copy-Item -LiteralPath (Join-Path $repoRoot "plugins\orchestration\scripts\on-session-end.ps1") -Destination $listenerDir + + @' +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$CliArguments +) + +[ordered]@{ + sequence = 1 + message_id = "message-1" + sender_run_id = "sender-1" + subject = "Listener test" + body = "Listener started from a path containing spaces." +} | ConvertTo-Json -Compress +'@ | Set-Content -LiteralPath $fakeCli -Encoding UTF8 + + $env:OZ_CLI = $fakeCli + $env:OZ_RUN_ID = "run-1" + $env:OZ_PARENT_RUN_ID = "parent-1" + $env:OZ_PARENT_STATE_ROOT = $stateRoot + $env:OZ_PARENT_LISTENER_MANAGED_EXTERNALLY = "" + + [void](Invoke-PowerShellHook (Join-Path $listenerDir "on-session-start.ps1") '{"session_id":"session-1"}') + + $deadline = (Get-Date).AddSeconds(10) + $stagedFiles = @() + do { + $stagedFiles = @(Get-ChildItem -LiteralPath (Join-Path $stateDir "staged") -Filter "*.json" -ErrorAction SilentlyContinue) + if ($stagedFiles.Count -gt 0) { + break + } + Start-Sleep -Milliseconds 100 + } while ((Get-Date) -lt $deadline) + + Assert-Equal 1 $stagedFiles.Count "Listener did not stage exactly one message." + $message = Get-Content -Raw -LiteralPath $stagedFiles[0].FullName | ConvertFrom-Json + Assert-Equal "message-1" $message.message_id "Listener staged the wrong message." + + [void](Invoke-PowerShellHook (Join-Path $listenerDir "on-session-end.ps1") '{"session_id":"session-1"}') + Assert-True (-not (Test-Path -LiteralPath $stateDir)) "SessionEnd did not clean up listener state." + } finally { + if (Test-Path -LiteralPath $stateDir) { + . (Join-Path $listenerDir "oz-parent-common.ps1") + Stop-Listener $stateDir + } + $env:OZ_CLI = $oldOzCli + $env:OZ_RUN_ID = $oldOzRunId + $env:OZ_PARENT_RUN_ID = $oldOzParentRunId + $env:OZ_PARENT_STATE_ROOT = $oldStateRoot + $env:OZ_PARENT_LISTENER_MANAGED_EXTERNALLY = $oldManagedExternally + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Test-PowerShellSyntax +Test-WindowsHookCommands +Test-WarpPayload +Test-WarpHookEntryPoints +Test-ListenerLaunchWithSpaces + +Write-Host "PowerShell hook tests passed." From be10d0c715e096b4d5994e922bde67ecf301a589 Mon Sep 17 00:00:00 2001 From: andrejoe630 Date: Wed, 15 Jul 2026 12:52:39 -0700 Subject: [PATCH 4/4] Fix Windows notification console transport --- .agents/plugins/marketplace.json | 4 +- README.md | 2 +- .../orchestration/.codex-plugin/plugin.json | 2 +- plugins/warp/.codex-plugin/plugin.json | 2 +- plugins/warp/scripts/common.ps1 | 101 +++++++++++++++--- plugins/warp/scripts/on-session-start.ps1 | 2 +- plugins/warp/scripts/on-session-start.sh | 2 +- tests/test-hooks.ps1 | 9 ++ 8 files changed, 105 insertions(+), 19 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index e05e08b..c68248b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -14,7 +14,7 @@ "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, - "version": "0.4.0", + "version": "0.4.1", "category": "productivity", "tags": ["notifications", "terminal", "warp"] }, @@ -26,7 +26,7 @@ "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, - "version": "0.4.0", + "version": "0.4.1", "category": "productivity", "tags": ["terminal", "warp", "oz"] } diff --git a/README.md b/README.md index 17be8df..970a741 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ This uses a fake `oz` CLI and a temp `CODEX_HOME`. It validates parent-message staging/drain/blocking and plugin manifests. ## Versioning `plugins/warp/scripts/on-session-start.sh` emits `PLUGIN_VERSION`. -Current plugin version: `0.4.0`. +Current plugin version: `0.4.1`. Keep it in sync with Warp's Codex plugin manager minimum version. ## Requirements - Codex CLI with plugin support diff --git a/plugins/orchestration/.codex-plugin/plugin.json b/plugins/orchestration/.codex-plugin/plugin.json index d97c805..0f09daf 100644 --- a/plugins/orchestration/.codex-plugin/plugin.json +++ b/plugins/orchestration/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "orchestration", "description": "Warp integration for Codex in Oz cloud agent environments, including parent-message delivery hooks and skills", - "version": "0.4.0", + "version": "0.4.1", "author": { "name": "Warp", "url": "https://warp.dev" diff --git a/plugins/warp/.codex-plugin/plugin.json b/plugins/warp/.codex-plugin/plugin.json index ebe510d..4020360 100644 --- a/plugins/warp/.codex-plugin/plugin.json +++ b/plugins/warp/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "warp", "description": "Warp terminal integration for Codex - native notifications, and more to come", - "version": "0.4.0", + "version": "0.4.1", "author": { "name": "Warp", "url": "https://warp.dev" diff --git a/plugins/warp/scripts/common.ps1 b/plugins/warp/scripts/common.ps1 index 6c91da7..07207f0 100644 --- a/plugins/warp/scripts/common.ps1 +++ b/plugins/warp/scripts/common.ps1 @@ -112,6 +112,93 @@ function New-WarpPayload { return ($payload | ConvertTo-Json -Compress -Depth 50) } +function Initialize-WarpConsoleWriter { + if ("WarpConsoleWriter" -as [type]) { + return + } + + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +public static class WarpConsoleWriter +{ + private const uint GenericWrite = 0x40000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool WriteConsoleW( + IntPtr consoleOutput, + string buffer, + uint charsToWrite, + out uint charsWritten, + IntPtr reserved); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + + public static bool Write(string message) + { + IntPtr handle = CreateFileW( + "CONOUT$", + GenericWrite, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + + if (handle == new IntPtr(-1)) + { + return false; + } + + try + { + int offset = 0; + while (offset < message.Length) + { + int chunkLength = Math.Min(16384, message.Length - offset); + if (offset + chunkLength < message.Length + && char.IsHighSurrogate(message[offset + chunkLength - 1])) + { + chunkLength--; + } + + string chunk = message.Substring(offset, chunkLength); + uint written; + if (!WriteConsoleW(handle, chunk, (uint)chunk.Length, out written, IntPtr.Zero) + || written == 0) + { + return false; + } + + offset += (int)written; + } + + return true; + } + finally + { + CloseHandle(handle); + } + } +} +"@ +} + function Send-WarpNotification { param( [string]$Title, @@ -123,18 +210,8 @@ function Send-WarpNotification { $message = "$escape]777;notify;$Title;$Body$bell" try { - $stream = [System.IO.File]::Open("CONOUT$", [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Write) - try { - $writer = New-Object System.IO.StreamWriter($stream, [Console]::OutputEncoding) - $writer.AutoFlush = $true - $writer.Write($message) - } finally { - if ($writer) { - $writer.Dispose() - } else { - $stream.Dispose() - } - } + Initialize-WarpConsoleWriter + [void][WarpConsoleWriter]::Write($message) } catch { # Hook stdout is reserved for Codex hook control JSON. If there is no # attached console device, drop the notification rather than emitting diff --git a/plugins/warp/scripts/on-session-start.ps1 b/plugins/warp/scripts/on-session-start.ps1 index be51c6d..473b15b 100644 --- a/plugins/warp/scripts/on-session-start.ps1 +++ b/plugins/warp/scripts/on-session-start.ps1 @@ -7,7 +7,7 @@ if (-not (Test-ShouldUseStructured)) { exit 0 } -$pluginVersion = "0.4.0" +$pluginVersion = "0.4.1" $inputJson = Read-HookInput $body = New-WarpPayload $inputJson "session_start" @{ plugin_version = $pluginVersion diff --git a/plugins/warp/scripts/on-session-start.sh b/plugins/warp/scripts/on-session-start.sh index af5fb59..cf72380 100755 --- a/plugins/warp/scripts/on-session-start.sh +++ b/plugins/warp/scripts/on-session-start.sh @@ -5,7 +5,7 @@ set -euo pipefail # Bump on every release; keep in sync with Warp's Codex plugin manager. -PLUGIN_VERSION="0.4.0" +PLUGIN_VERSION="0.4.1" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/should-use-structured.sh" diff --git a/tests/test-hooks.ps1 b/tests/test-hooks.ps1 index 02dee3f..abc890c 100644 --- a/tests/test-hooks.ps1 +++ b/tests/test-hooks.ps1 @@ -112,6 +112,14 @@ function Test-WarpPayload { Assert-Equal "ok" $payload.detail "Warp payload extra field mismatch." } +function Test-WarpConsoleWriterInterop { + . (Join-Path $repoRoot "plugins\warp\scripts\common.ps1") + + Initialize-WarpConsoleWriter + Assert-True ($null -ne ("WarpConsoleWriter" -as [type])) "Warp console writer type was not loaded." + [void][WarpConsoleWriter]::Write("") +} + function Test-WarpHookEntryPoints { $oldProtocolVersion = $env:WARP_CLI_AGENT_PROTOCOL_VERSION $oldClientVersion = $env:WARP_CLIENT_VERSION @@ -205,6 +213,7 @@ param( Test-PowerShellSyntax Test-WindowsHookCommands Test-WarpPayload +Test-WarpConsoleWriterInterop Test-WarpHookEntryPoints Test-ListenerLaunchWithSpaces