diff --git a/docs/agent-types.md b/docs/agent-types.md index a14e57b9..6c196280 100644 --- a/docs/agent-types.md +++ b/docs/agent-types.md @@ -31,6 +31,7 @@ a manifest cannot execute code. Multi-value keys are whitespace-separated. | `delivery_modes` | — | space-separated delivery modes the type's CLI accepts (e.g. `monitor turn off`); `delivery.sh`'s gate rejects anything else. Defaults to `monitor turn both off` when omitted | | `stop_output` | — | output protocol for the Stop/turn inbox check — `json` (codex, copilot) vs. plain text (default) | | `hook_windows_wrap` | — | `yes` if JSON hook entries also need a Windows-native `commandWindows` variant (codex) | +| `hook_windows_transport` | — | `queue` to make SessionStart/SessionEnd `commandWindows` enqueue native PowerShell requests for the external dispatcher; omitted keeps `hook_windows_wrap` behavior | > The reader does not fail-fast: an omitted key reads as the empty string, so > "required" above means "needed for the type to actually work", not "validated at @@ -115,5 +116,6 @@ hooks_file=.codex/hooks.json monitor=no stop_output=json hook_windows_wrap=yes +hook_windows_transport=queue delivery_modes=monitor turn off ``` diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 6eef241f..c3ef689b 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -43,6 +43,20 @@ The Codex sandbox must allow writes to the installed skill's runtime state: `install.sh` and `install.sh --update` add these writable roots to `~/.codex/config.toml` when that file exists. +### Windows hook dispatcher + +On Windows, Codex SessionStart/SessionEnd hooks use a native PowerShell queue so +the sandbox does not need to launch Git Bash. Register the per-user dispatcher +once after installing or updating agmsg: + +```powershell +& "$HOME\.agents\skills\agmsg\scripts\windows\install-hook-dispatcher.ps1" -Register +``` + +The dispatcher polls the installed skill's queue and monitor-enabled projects, +then runs the queued hook through Git Bash outside the Codex sandbox. Use +`-Status` to check it or `-Unregister` to remove the scheduled task. + Add the printed function to your shell profile. It looks like: ```bash diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 9ae0e94d..982e1293 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -92,12 +92,17 @@ agmsg_delivery_apply_default() { hooks_file=$(resolve_hooks_file "$type" "$project") mkdir -p "$(dirname "$hooks_file")" - # Whether hook entries also need a Windows-native "commandWindows" variant is - # a per-type manifest fact (hook_windows_wrap=yes). Resolve it here — the layer - # that knows agent types — and pass a plain flag down to add_event_entry_file, - # which stays type-agnostic (see hooks-json.sh header). + # Windows hook handling is manifest data: hook_windows_wrap=yes enables the + # legacy Git Bash wrapper, while hook_windows_transport=queue routes only + # SessionStart/SessionEnd through the native enqueue command. Resolve that + # policy here, where agent types are known, and pass plain values to the JSON + # helper. local ww ww=$(agmsg_type_get "$type" hook_windows_wrap 2>/dev/null || true) + local windows_transport session_windows + windows_transport=$(agmsg_type_get "$type" hook_windows_transport 2>/dev/null || true) + session_windows="$ww" + [ "$windows_transport" = "queue" ] && session_windows="queue" # Work on a temp copy so a partially-modified file never replaces the # original until the whole chain succeeds. @@ -119,8 +124,8 @@ agmsg_delivery_apply_default() { monitor) local ss="'$SKILL_DIR/scripts/session-start.sh' '$type' '$project'" local se="'$SKILL_DIR/scripts/session-end.sh' '$type' '$project'" - add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$ww" - add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$ww" + add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$session_windows" "$type" "$project" + add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$session_windows" "$type" "$project" ;; turn) local cmd="'$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project'" @@ -130,8 +135,8 @@ agmsg_delivery_apply_default() { local ss="'$SKILL_DIR/scripts/session-start.sh' '$type' '$project'" local se="'$SKILL_DIR/scripts/session-end.sh' '$type' '$project'" local st="'$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project'" - add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$ww" - add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$ww" + add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$session_windows" "$type" "$project" + add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$session_windows" "$type" "$project" add_event_entry_file "$tmp_state" "Stop" "$st" "$ww" ;; off) diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh index cb1611f4..779529af 100644 --- a/scripts/drivers/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -2,14 +2,51 @@ # codex delivery plug. # # codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it adds -# enable/disable side effects (print the monitor shim setup on enable, stop the -# bridge on disable) and replaces the runtime status summary with Codex bridge -# liveness. Sourced into delivery.sh's context, so SKILL_DIR, SCRIPT_DIR, +# enable/disable side effects (maintain the Windows hook project list, print the +# monitor shim setup on enable, stop the bridge on disable) and replaces the +# runtime status summary with Codex bridge liveness. Sourced into delivery.sh's +# context, so SKILL_DIR, SCRIPT_DIR, # RUN_DIR, agmsg_resolve_node, CODEX_MONITOR_DOC_URL and stop_codex_bridge are # in scope. # Args (both hooks): on_enable ; on_disable . +agmsg_codex_hook_project_add() { + local project="$1" + local list="$RUN_DIR/hook-projects.list" + local tmp line found=0 + mkdir -p "$RUN_DIR" + tmp=$(mktemp "$RUN_DIR/hook-projects.XXXXXX") || return 1 + if [ -f "$list" ]; then + while IFS= read -r line || [ -n "$line" ]; do + if [ "$line" = "$project" ]; then + if [ "$found" -eq 0 ]; then + printf '%s\n' "$line" >> "$tmp" + found=1 + fi + else + printf '%s\n' "$line" >> "$tmp" + fi + done < "$list" + fi + [ "$found" -eq 1 ] || printf '%s\n' "$project" >> "$tmp" + mv "$tmp" "$list" +} + +agmsg_codex_hook_project_remove() { + local project="$1" + local list="$RUN_DIR/hook-projects.list" + local tmp line + [ -f "$list" ] || return 0 + tmp=$(mktemp "$RUN_DIR/hook-projects.XXXXXX") || return 1 + while IFS= read -r line || [ -n "$line" ]; do + [ "$line" = "$project" ] || printf '%s\n' "$line" >> "$tmp" + done < "$list" + mv "$tmp" "$list" +} + agmsg_delivery_on_enable() { + local project="$3" + agmsg_codex_hook_project_add "$project" echo "Codex monitor beta is enabled." echo "Add this shell function to your interactive shell profile, then restart the shell:" if "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" function; then @@ -37,6 +74,7 @@ agmsg_delivery_on_enable() { agmsg_delivery_on_disable() { local project="$2" + agmsg_codex_hook_project_remove "$project" local stopped stopped=$(stop_codex_bridge "$project") if [ "${stopped:-0}" -gt 0 ]; then diff --git a/scripts/drivers/types/codex/type.conf b/scripts/drivers/types/codex/type.conf index 33a04943..cd7ae3ab 100644 --- a/scripts/drivers/types/codex/type.conf +++ b/scripts/drivers/types/codex/type.conf @@ -10,4 +10,5 @@ hooks_file=.codex/hooks.json monitor=no stop_output=json hook_windows_wrap=yes +hook_windows_transport=queue delivery_modes=monitor turn off diff --git a/scripts/lib/hooks-json.sh b/scripts/lib/hooks-json.sh index 97d3802a..eaedd884 100644 --- a/scripts/lib/hooks-json.sh +++ b/scripts/lib/hooks-json.sh @@ -92,11 +92,27 @@ windows_wrap() { printf "\$b=\$env:GIT_BASH; if (-not \$b) { \$b=\$env:AGMSG_BASH }; if (-not \$b) { \$b='C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe' }; & \$b -lc '%s'" "$bash_cmd_ps" } +# Build a native PowerShell hook command that only enqueues the event. The +# dispatcher later invokes Git Bash outside the Codex Windows sandbox. +windows_enqueue_wrap() { + local event="$1" + local type="$2" + local project="$3" + local enqueue_path="$SKILL_DIR/scripts/windows/hook-enqueue.ps1" + if command -v cygpath >/dev/null 2>&1; then + enqueue_path=$(cygpath -w "$enqueue_path" 2>/dev/null || printf '%s' "$enqueue_path") + fi + enqueue_path=$(printf '%s' "$enqueue_path" | sed "s/'/''/g") + type=$(printf '%s' "$type" | sed "s/'/''/g") + project=$(printf '%s' "$project" | sed "s/'/''/g") + printf "& '%s' '%s' '%s' '%s'" "$enqueue_path" "$event" "$type" "$project" +} + # Append a single entry of the form {"matcher":"","hooks":[{"type":"command","command":""}]} # to .hooks. in the JSON at , creating arrays/objects as needed. -# When the 4th arg is "yes" the entry also carries a "commandWindows" so the hook -# runs on native Windows; otherwise it is omitted. This layer stays type-agnostic -# — the caller (delivery.sh) decides from the type manifest whether to wrap. +# When the 4th arg is "yes" the entry carries the legacy Git Bash wrapper. When +# it is "queue", SessionStart/SessionEnd use the native enqueue transport. The +# caller supplies type/project as args 5/6 for that queue command. # Writes the result back to . As with strip_agmsg_event_file, the settings # are read via readfile() rather than via argv (#95). add_event_entry_file() { @@ -104,6 +120,8 @@ add_event_entry_file() { local event="$2" local cmd="$3" local windows_wrap="${4:-}" + local hook_type="${5:-}" + local hook_project="${6:-}" local sql_path sql_path=$(sql_readfile_path "$path") @@ -121,6 +139,18 @@ add_event_entry_file() { cw=$(windows_wrap "$cmd") cw_lit=$(printf '%s' "$cw" | sed "s/'/''/g") hook_obj="$hook_obj,'commandWindows','$cw_lit'" + elif [ "$windows_wrap" = "queue" ]; then + local queue_event cw cw_lit + case "$event" in + SessionStart) queue_event="session-start" ;; + SessionEnd) queue_event="session-end" ;; + *) queue_event="" ;; + esac + if [ -n "$queue_event" ]; then + cw=$(windows_enqueue_wrap "$queue_event" "$hook_type" "$hook_project") + cw_lit=$(printf '%s' "$cw" | sed "s/'/''/g") + hook_obj="$hook_obj,'commandWindows','$cw_lit'" + fi fi hook_obj="$hook_obj)" local entry_sql="json_object('matcher','','hooks',json_array($hook_obj))" diff --git a/scripts/windows/hook-dispatcher.ps1 b/scripts/windows/hook-dispatcher.ps1 new file mode 100644 index 00000000..285858bb --- /dev/null +++ b/scripts/windows/hook-dispatcher.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [switch] $Once, + [ValidateRange(1, 3600)] [int] $PollSeconds = 1 +) + +$ErrorActionPreference = 'Stop' +$SkillDir = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$RunDir = Join-Path $SkillDir 'run' +$PidFile = Join-Path $RunDir 'hook-dispatcher.pid' +$LogFile = Join-Path $RunDir 'hook-dispatcher.log' +$ProjectList = Join-Path $RunDir 'hook-projects.list' + +function Write-DispatcherLog { + param([string] $Message) + try { + if ((Test-Path -LiteralPath $LogFile) -and (Get-Item -LiteralPath $LogFile).Length -gt 1MB) { + [IO.File]::WriteAllText($LogFile, '') + } + Add-Content -LiteralPath $LogFile -Value ('{0:o} {1}' -f [DateTime]::UtcNow, $Message) -Encoding UTF8 + } catch { + # Logging must not terminate the dispatcher. + } +} + +function Test-ProcessAlive { + param([string] $ProcessId) + if ($ProcessId -notmatch '^\d+$') { return $false } + return $null -ne (Get-Process -Id ([int]$ProcessId) -ErrorAction SilentlyContinue) +} + +function ConvertTo-MsysPath { + param([string] $Path) + if ($Path -match '^([A-Za-z]):[\\/](.*)$') { + return '/' + $Matches[1].ToLowerInvariant() + '/' + ($Matches[2] -replace '\\', '/') + } + if ($Path -match '^\\\\') { + return '/' + ($Path.TrimStart('\') -replace '\\', '/') + } + return ($Path -replace '\\', '/') +} + +function Quote-BashArgument { + param([string] $Value) + $singleQuote = [string][char]39 + $replacement = [string][char]39 + [char]34 + [char]39 + [char]34 + [char]39 + return $singleQuote + $Value.Replace($singleQuote, $replacement) + $singleQuote +} + +function Get-QueueDirectories { + $seen = @{} + $dirs = @((Join-Path $RunDir 'hook-queue')) + if (Test-Path -LiteralPath $ProjectList) { + foreach ($project in [IO.File]::ReadAllLines($ProjectList)) { + if (-not [string]::IsNullOrWhiteSpace($project)) { + $dirs += Join-Path $project.Trim() '.agmsg\hook-queue' + } + } + } + foreach ($dir in $dirs) { + $key = $dir.ToLowerInvariant() + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + $dir + } + } +} + +function Invoke-HookRequest { + param([IO.FileInfo] $Request, [string] $Bash) + try { + $raw = [IO.File]::ReadAllText($Request.FullName) + $newline = $raw.IndexOf("`n") + if ($newline -lt 0) { throw 'Request has no header terminator.' } + $header = $raw.Substring(0, $newline).TrimEnd("`r") + $stdin = $raw.Substring($newline + 1) + $fields = $header.Split("`t") + if ($fields.Count -lt 4) { throw 'Request header is malformed.' } + $event, $type, $project = $fields[0], $fields[1], $fields[2] + switch ($event) { + 'session-start' { $script = 'session-start.sh' } + 'session-end' { $script = 'session-end.sh' } + default { throw "Unsupported event: $event" } + } + + if (-not $Bash -or -not (Test-Path -LiteralPath $Bash)) { + throw "Git Bash not found: $Bash" + } + $scriptPath = (ConvertTo-MsysPath $SkillDir) + '/scripts/' + $script + $command = (Quote-BashArgument $scriptPath) + ' ' + + (Quote-BashArgument $type) + ' ' + (Quote-BashArgument $project) + Write-DispatcherLog ("request={0} event={1} type={2} script={3} status=run" -f $Request.Name, $event, $type, $script) + $output = @($stdin | & $Bash -lc $command 2>&1) + $exitCode = $LASTEXITCODE + foreach ($line in $output) { + Write-DispatcherLog ("request={0} output={1}" -f $Request.Name, [string]$line) + } + if ($exitCode -ne 0) { throw "hook exited $exitCode" } + Write-DispatcherLog ("request={0} event={1} type={2} status=ok" -f $Request.Name, $event, $type) + } catch { + Write-DispatcherLog ("request={0} status=failed error={1}" -f $Request.Name, $_.Exception.Message) + } finally { + Remove-Item -LiteralPath $Request.FullName -Force -ErrorAction SilentlyContinue + } +} + +New-Item -ItemType Directory -Force -Path $RunDir | Out-Null +if (Test-Path -LiteralPath $PidFile) { + $existingPid = (Get-Content -LiteralPath $PidFile -Raw -ErrorAction SilentlyContinue).Trim() + if (Test-ProcessAlive $existingPid) { exit 0 } +} +[IO.File]::WriteAllText($PidFile, [string]$PID) + +try { + $bash = $env:GIT_BASH + if (-not $bash) { $bash = $env:AGMSG_BASH } + if (-not $bash) { $bash = 'C:\Program Files\Git\bin\bash.exe' } + Write-DispatcherLog ("dispatcher start pid={0}" -f $PID) + + do { + foreach ($queueDir in Get-QueueDirectories) { + if (-not (Test-Path -LiteralPath $queueDir)) { continue } + foreach ($request in Get-ChildItem -LiteralPath $queueDir -Filter '*.req' -File | Sort-Object Name) { + Invoke-HookRequest $request $bash + } + } + if (-not $Once) { Start-Sleep -Seconds $PollSeconds } + } while (-not $Once) +} finally { + if (Test-Path -LiteralPath $PidFile) { + $recordedPid = (Get-Content -LiteralPath $PidFile -Raw -ErrorAction SilentlyContinue).Trim() + if ($recordedPid -eq [string]$PID) { + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + } + } + Write-DispatcherLog ("dispatcher stop pid={0}" -f $PID) +} diff --git a/scripts/windows/hook-enqueue.ps1 b/scripts/windows/hook-enqueue.ps1 new file mode 100644 index 00000000..0719e00f --- /dev/null +++ b/scripts/windows/hook-enqueue.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] [string] $Event, + [Parameter(Position = 1)] [string] $Type, + [Parameter(Position = 2)] [string] $Project +) + +# Hook delivery must never make a Codex session fail. Keep every operation, +# including the initial writable-directory probe, inside best-effort guards. +try { + $stdin = [Console]::In.ReadToEnd() + $epochMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $name = '{0}-{1}-{2}.req' -f $epochMs, $PID, ([Guid]::NewGuid().ToString('N').Substring(0, 12)) + $header = "{0}`t{1}`t{2}`t{3}" -f $Event, $Type, $Project, $epochMs + $content = $header + "`n" + $stdin + $encoding = New-Object System.Text.UTF8Encoding($false) + + $candidates = @() + $candidates += [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\run\hook-queue')) + if ($Project) { + $candidates += Join-Path $Project '.agmsg\hook-queue' + } + + $queued = $false + foreach ($queueDir in $candidates) { + $tmp = $null + try { + New-Item -ItemType Directory -Force -Path $queueDir -ErrorAction Stop | Out-Null + $tmp = Join-Path $queueDir ($name + '.tmp') + $dest = Join-Path $queueDir $name + [IO.File]::WriteAllText($tmp, $content, $encoding) + [IO.File]::Move($tmp, $dest) + $queued = $true + break + } catch { + if ($tmp -and (Test-Path -LiteralPath $tmp)) { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } + } + } + + if (-not $queued) { + throw 'No writable agmsg hook queue directory was found.' + } +} catch { + try { + $errPath = Join-Path $env:TEMP 'agmsg-hook-enqueue.err' + $line = '{0:o} event={1} type={2} error={3}' -f [DateTime]::UtcNow, $Event, $Type, $_.Exception.Message + Add-Content -LiteralPath $errPath -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue + } catch { + # Deliberately ignored: enqueue is fail-open for Codex sessions. + } +} + +exit 0 diff --git a/scripts/windows/install-hook-dispatcher.ps1 b/scripts/windows/install-hook-dispatcher.ps1 new file mode 100644 index 00000000..028e5ca9 --- /dev/null +++ b/scripts/windows/install-hook-dispatcher.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param( + [switch] $Register, + [switch] $Unregister, + [switch] $Status +) + +$ErrorActionPreference = 'Stop' +$TaskName = 'agmsg-hook-dispatcher' +$SkillDir = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$Dispatcher = Join-Path $PSScriptRoot 'hook-dispatcher.ps1' +$PidFile = Join-Path $SkillDir 'run\hook-dispatcher.pid' + +function Get-PowerShellExecutable { + $command = Get-Command pwsh.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $command) { + $command = Get-Command powershell.exe -ErrorAction Stop | Select-Object -First 1 + } + return $command.Source +} + +function Get-DispatcherPid { + if (-not (Test-Path -LiteralPath $PidFile)) { return $null } + $value = (Get-Content -LiteralPath $PidFile -Raw -ErrorAction SilentlyContinue).Trim() + if ($value -notmatch '^\d+$') { return $null } + return [int]$value +} + +function Test-DispatcherAlive { + $dispatcherPid = Get-DispatcherPid + if ($null -eq $dispatcherPid) { return $false } + return $null -ne (Get-Process -Id $dispatcherPid -ErrorAction SilentlyContinue) +} + +function Invoke-ScheduledTaskCommand { + param([string[]] $Arguments, [switch] $Quiet) + $savedPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'SilentlyContinue' + $output = @(& schtasks.exe @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedPreference + } + if (-not $Quiet) { + foreach ($line in $output) { Write-Host ([string]$line) } + } + return $exitCode +} + +$selected = 0 +foreach ($choice in @($Register, $Unregister, $Status)) { + if ($choice) { $selected++ } +} +if ($selected -ne 1) { + throw 'Specify exactly one of -Register, -Unregister, or -Status.' +} + +if ($Register) { + $powershell = Get-PowerShellExecutable + $taskCommand = '"{0}" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{1}"' -f $powershell, $Dispatcher + $taskExit = Invoke-ScheduledTaskCommand @('/Create', '/TN', $TaskName, '/SC', 'ONLOGON', '/TR', $taskCommand, '/F') + if ($taskExit -ne 0) { throw "schtasks /Create failed with exit code $taskExit" } + Start-Process -FilePath $powershell -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', $Dispatcher) -WindowStyle Hidden + Write-Output "Registered and started $TaskName." + exit 0 +} + +if ($Unregister) { + $dispatcherPid = Get-DispatcherPid + if ($null -ne $dispatcherPid -and (Test-DispatcherAlive)) { + Stop-Process -Id $dispatcherPid -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue + [void](Invoke-ScheduledTaskCommand @('/Delete', '/TN', $TaskName, '/F') -Quiet) + Write-Output "Unregistered and stopped $TaskName." + exit 0 +} + +$taskExit = Invoke-ScheduledTaskCommand @('/Query', '/TN', $TaskName) -Quiet +$taskRegistered = ($taskExit -eq 0) +$dispatcherAlive = Test-DispatcherAlive +Write-Output ('Task registered: {0}' -f $(if ($taskRegistered) { 'yes' } else { 'no' })) +Write-Output ('Dispatcher alive: {0}' -f $(if ($dispatcherAlive) { 'yes' } else { 'no' })) +if ($dispatcherAlive) { Write-Output ('Dispatcher pid: {0}' -f (Get-DispatcherPid)) } diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index a307e2a5..b7f8cd9b 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1059,6 +1059,66 @@ JSON # --- Windows support: codex hooks emit commandWindows; other types do not --- +@test "delivery set monitor (codex): Session hooks use native queue commandWindows" { + run bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" + [ "$status" -eq 0 ] + local hook_file="$TEST_PROJECT/.codex/hooks.json" + [ -f "$hook_file" ] + local start end + start=$(sqlite_mem "SELECT json_extract(readfile('$(rf "$hook_file")'), '\$.hooks.SessionStart[0].hooks[0].commandWindows');") + end=$(sqlite_mem "SELECT json_extract(readfile('$(rf "$hook_file")'), '\$.hooks.SessionEnd[0].hooks[0].commandWindows');") + [[ "$start" == *"hook-enqueue.ps1"* ]] + [[ "$start" == *"session-start"* ]] + [[ "$start" == *"codex"* ]] + [[ "$start" == *"$TEST_PROJECT"* ]] + [[ "$end" == *"hook-enqueue.ps1"* ]] + [[ "$end" == *"session-end"* ]] + [[ "$start" != *"bash.exe"* ]] + [[ "$start" != *"-lc"* ]] + [[ "$end" != *"bash.exe"* ]] + [[ "$end" != *"-lc"* ]] +} + +@test "delivery set monitor: type without hook_windows_transport keeps Git Bash commandWindows" { + local type_dir="$TYPES/legacy-win" + mkdir -p "$type_dir" + cat > "$type_dir/type.conf" <<'EOF' +name=legacy-win +hooks_file=.legacy/hooks.json +hook_windows_wrap=yes +delivery_modes=monitor off +EOF + + run bash "$SCRIPTS/delivery.sh" set monitor legacy-win "$TEST_PROJECT" + [ "$status" -eq 0 ] + local hook_file="$TEST_PROJECT/.legacy/hooks.json" + [ -f "$hook_file" ] + local cw + cw=$(sqlite_mem "SELECT json_extract(readfile('$(rf "$hook_file")'), '\$.hooks.SessionStart[0].hooks[0].commandWindows');") + [[ "$cw" == *"Program Files\\Git\\bin\\bash.exe"* ]] + [[ "$cw" == *"GIT_BASH"* ]] + [[ "$cw" == *"-lc"* ]] + [[ "$cw" == *"session-start.sh"* ]] + [[ "$cw" != *"hook-enqueue.ps1"* ]] +} + +@test "delivery set monitor/off (codex): hook project list deduplicates and removes only target" { + local other_project="$TEST_SKILL_DIR/other-project" + local list="$TEST_SKILL_DIR/run/hook-projects.list" + mkdir -p "$other_project" + + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null + [ "$(grep -Fxc -- "$TEST_PROJECT" "$list")" = "1" ] + + bash "$SCRIPTS/delivery.sh" set monitor codex "$other_project" >/dev/null + [ "$(grep -Fxc -- "$other_project" "$list")" = "1" ] + + bash "$SCRIPTS/delivery.sh" set off codex "$TEST_PROJECT" >/dev/null + ! grep -Fxq -- "$TEST_PROJECT" "$list" + [ "$(grep -Fxc -- "$other_project" "$list")" = "1" ] +} + @test "delivery set turn (codex): Stop entry carries commandWindows wrapping Git Bash" { skip_on_windows "commandWindows not written on native Windows (#182)" run bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT"