Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/agent-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
14 changes: 14 additions & 0 deletions docs/codex-monitor-beta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions scripts/delivery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'"
Expand All @@ -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)
Expand Down
44 changes: 41 additions & 3 deletions scripts/drivers/types/codex/_delivery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode> <type> <project>; on_disable <type> <project>.

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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/drivers/types/codex/type.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 33 additions & 3 deletions scripts/lib/hooks-json.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,36 @@ 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":"<cmd>"}]}
# to .hooks.<event> in the JSON at <path>, 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 <path>. As with strip_agmsg_event_file, the settings
# are read via readfile() rather than via argv (#95).
add_event_entry_file() {
local path="$1"
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")

Expand All @@ -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))"
Expand Down
137 changes: 137 additions & 0 deletions scripts/windows/hook-dispatcher.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
Loading