一、需求背景

我想实现这样一个效果: `VS Code` 里的 `Codex` 插件在任务执行完成后,自动给我的安卓手机发一条通知。 我使用的是: - Windows - VS Code - VS Code 内的 Codex 插件 - 安卓手机 - `ntfy` 作为推送通道 本文所有路径和主题名都已经脱敏,示例中统一使用占位符: - Windows 用户目录:`C:\Users\<YourUser>` - Codex 目录:`C:\Users\<YourUser>\.codex` - ntfy 主题:`<YOUR_NTFY_TOPIC>`

二、先说结论

一开始我以为只要在 `config.toml` 里配置 `notify = [...]` 就够了,但在 **Windows + VS Code 插件版 Codex** 这个组合下,实际会遇到一个坑: 内置 `notify` 确实会触发,但 **任务完成时传给脚本的参数可能过长**,从而导致 Windows 侧执行失败,日志里会出现类似: ```text after_agent hook failed ... hook_name=legacy_notify ... (os error 206)

所以更稳的方案不是继续死磕内置 notify,而是:

监听 C:\Users\<YourUser>\.codex\sessions 里的 task_complete 事件,再主动调用自己的通知脚本。

三、我的环境

本文对应的设备和软件环境如下:

  • 操作系统:Windows

  • 编辑器:VS Code

  • AI 插件:Codex

  • Codex 工作目录:C:\Users\<YourUser>\.codex

  • 通知方案:ntfy

  • 手机:Android

  • VS Code 设置里未启用 WSL 模式

四、先验证 ntfy 链路是否打通

先不要碰 Codex,先确认 Windows -> ntfy.sh -> 安卓手机 这条链路是通的。

curl.exe -v -d "hello from windows" https://ntfy.sh/<YOUR_NTFY_TOPIC>

如果手机能收到通知,说明 ntfy 本身没问题。

五、先准备一个最小通知脚本

在下面这个位置创建文件:

C:\Users\<YourUser>\.codex\codex_ntfy_notify.ps1

内容如下:

param($Json) $log = "$env:USERPROFILE\.codex\notify_log.txt" Add-Content -Path $log -Value ("==== " + (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + " ====") Add-Content -Path $log -Value ("ARG: " + $Json) $topic = "<YOUR_NTFY_TOPIC>" $url = "https://ntfy.sh/$topic" $body = "Codex task done. Check VS Code." try { curl.exe -s -H "Title: Codex Done" -H "Priority: high" -H "Tags: computer" -d "$body" "$url" | Out-Null Add-Content -Path $log -Value "SEND: OK" } catch { Add-Content -Path $log -Value ("SEND: ERROR " + $_.Exception.Message) }

手动测试:

& "C:\Users\<YourUser>\.codex\codex_ntfy_notify.ps1" '{"type":"agent-turn-complete","last-assistant-message":"test"}'

如果手机收到通知,并且 notify_log.txt 有新增记录,说明这个脚本是正常的。

六、为什么内置 notify 在 VS Code 插件版里不稳

我做完排查后的结论是:

  • VS Code 版 Codex会读取C:\Users\<YourUser>\.codex\config.toml

  • VS Code 版 Codex也支持notify

  • 真正的问题不是“不支持”,而是Windows 启动 notify 时参数过长

也就是说:

  • 不是ntfy有问题

  • 不是 PowerShell 脚本有问题

  • 而是 Codex 结束任务时传给notify的那段 JSON 可能太长,导致 Windows 报os error 206

所以我最后改成了外部 watcher 方案。

七、最终方案结构

最终结构如下:

VS Code Codex 插件 -> 写入 C:\Users\<YourUser>\.codex\sessions\...\rollout-*.jsonl -> PowerShell watcher 轮询这些 jsonl -> 发现 task_complete -> 提取短摘要 -> 调用 codex_ntfy_notify.ps1 -> ntfy.sh -> 安卓手机收到通知

这个方案的好处:

  • 不依赖内置notify的超长参数

  • 直接监听结构化的task_complete事件

  • 不需要修改项目代码

  • 对 VS Code 插件版 Codex 更稳

八、主 watcher 脚本

创建:

C:\Users\<YourUser>\.codex\codex_task_complete_watch.ps1

内容如下:

param( [string]$CodexHome = "$env:USERPROFILE\.codex", [int]$PollIntervalMs = 1200 ) $sessionRoot = Join-Path $CodexHome "sessions" $notifyScript = Join-Path $CodexHome "codex_ntfy_notify.ps1" $watchLog = Join-Path $CodexHome "codex_watch_log.txt" $stateDir = Join-Path $CodexHome "tmp" $seenPath = Join-Path $stateDir "codex_task_complete_seen.json" if (-not (Test-Path -LiteralPath $stateDir)) { New-Item -ItemType Directory -Path $stateDir | Out-Null } if (-not (Test-Path -LiteralPath $seenPath)) { Set-Content -LiteralPath $seenPath -Value "[]" } $seenTurns = @() try { $seenTurns = @(Get-Content -LiteralPath $seenPath -Raw | ConvertFrom-Json) } catch {} $fileOffsets = @{} function Write-WatchLog($msg) { Add-Content -Path $watchLog -Value ("[" + (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + "] " + $msg) } function Save-SeenTurns($items) { $json = ConvertTo-Json -InputObject @($items | Sort-Object -Unique) -Compress [System.IO.File]::WriteAllText($seenPath, $json, [System.Text.UTF8Encoding]::new($false)) } Write-WatchLog "Watcher started" while ($true) { $files = Get-ChildItem -LiteralPath $sessionRoot -Recurse -File -Filter "*.jsonl" -ErrorAction SilentlyContinue foreach ($file in $files) { if (-not $fileOffsets.ContainsKey($file.FullName)) { $fileOffsets[$file.FullName] = 0L Write-WatchLog ("Tracking new file: " + $file.FullName) } $stream = [System.IO.File]::Open($file.FullName, 'Open', 'Read', 'ReadWrite') try { $offset = [long]$fileOffsets[$file.FullName] if ($offset -gt $stream.Length) { $offset = 0 } $stream.Seek($offset, [System.IO.SeekOrigin]::Begin) | Out-Null $reader = New-Object System.IO.StreamReader($stream) while (-not $reader.EndOfStream) { $line = $reader.ReadLine() try { $entry = $line | ConvertFrom-Json -ErrorAction Stop if ($entry.type -eq "event_msg" -and $entry.payload.type -eq "task_complete") { $turnId = [string]$entry.payload.turn_id if ($seenTurns -contains $turnId) { continue } $summary = [string]$entry.payload.last_agent_message $summary = ($summary -replace "\s+", " ").Trim() if ($summary.Length -gt 160) { $summary = $summary.Substring(0, 160) + "..." } $payload = @{ type = "task_complete" source = "codex_session_watcher" turn_id = $turnId summary = $summary detected_at = (Get-Date).ToString("s") } | ConvertTo-Json -Compress & $notifyScript $payload $seenTurns = @($seenTurns + $turnId) Save-SeenTurns $seenTurns Write-WatchLog ("Notified turn_id=" + $turnId) } } catch {} } $fileOffsets[$file.FullName] = $stream.Position $reader.Dispose() } finally { $stream.Dispose() } } Start-Sleep -Milliseconds $PollIntervalMs }

九、启动和停止脚本

启动脚本:

C:\Users\<YourUser>\.codex\codex_task_complete_watch_start.ps1

$watcher = "$env:USERPROFILE\.codex\codex_task_complete_watch.ps1" $pidPath = "$env:USERPROFILE\.codex\codex_task_complete_watch.pid" $proc = Start-Process -FilePath "powershell.exe" -ArgumentList @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $watcher ) -WindowStyle Hidden -PassThru Set-Content -LiteralPath $pidPath -Value $proc.Id Write-Output ("Watcher started. PID=" + $proc.Id)

停止脚本:

C:\Users\<YourUser>\.codex\codex_task_complete_watch_stop.ps1

$pidPath = "$env:USERPROFILE\.codex\codex_task_complete_watch.pid" if (Test-Path -LiteralPath $pidPath) { $watchPid = [int](Get-Content -LiteralPath $pidPath -Raw).Trim() $proc = Get-Process -Id $watchPid -ErrorAction SilentlyContinue if ($proc) { Stop-Process -Id $watchPid } Remove-Item -LiteralPath $pidPath -ErrorAction SilentlyContinue }

十、使用方式

启动 watcher:

& "C:\Users\<YourUser>\.codex\codex_task_complete_watch_start.ps1"

停止 watcher:

& "C:\Users\<YourUser>\.codex\codex_task_complete_watch_stop.ps1"

十一、运行后会生成哪些文件

运行后通常会看到这些文件:

  • 通知日志:C:\Users\<YourUser>\.codex\notify_log.txt

  • watcher 日志:C:\Users\<YourUser>\.codex\codex_watch_log.txt

  • 已处理 turn_id 去重文件:C:\Users\<YourUser>\.codex\tmp\codex_task_complete_seen.json

  • watcher PID 文件:C:\Users\<YourUser>\.codex\codex_task_complete_watch.pid

十二、最终效果

我这边最终验证通过的点有:

  • watcher 能后台启动

  • watcher 能识别task_complete

  • watcher 能调用 ntfy 通知脚本

  • 安卓手机能收到通知

  • notify_log.txt有SEND: OK

  • codex_watch_log.txt有Notified turn_id=...

  • codex_task_complete_seen.json能写入 turn id,避免重复通知

十三、结论

如果你用的是 Windows + VS Code 插件版 Codex,又想在任务完成后收到手机通知,那么最稳的做法是:

不要依赖内置 notify 去直接承接长参数事件,而是监听 .codex\sessions 里的 task_complete 事件,再转发给 ntfy。

这样做的核心价值是:

  • 绕开 Windowsos error 206

  • 不改项目代码

  • 兼容 VS Code 插件版 Codex

  • 实现简单,可控性高

更多推荐