1. 为什么 VS Code 里的 Codex 跑完任务,我却总是最后一个知道
我平时在 Windows 上用 VS Code 写代码,Codex 插件负责处理一些批量重构、补测试、生成脚本的活。问题出在等待上:一个稍大的任务动辄跑三五分钟,我习惯切到浏览器查文档,或者去倒杯水,回来一看任务早就结束了,白白浪费了等待窗口。更尴尬的是有时候任务其实失败了,我却以为还在跑,干等了十分钟。
我想要的效果很朴素:Codex 在 VS Code 里把任务跑完的那一刻,我的安卓手机能收到一条推送,标题写清楚是哪个任务完成,正文带一小段结果摘要。这样我就能安心去干别的,手机一震再回来处理。
这套链路的核心组件有三个。VS Code 是编辑器,Codex 插件在里面执行任务并把过程写进本地会话文件。PowerShell 是 Windows 自带的脚本引擎,负责监听会话文件的变化。ntfy 是一个开源推送服务,安卓端装个 App 订阅一个主题,就能收到 HTTP 发过来的消息。三者串起来,就实现了「任务完成 → 手机通知」。
适合谁看这篇:在 Windows 上用 VS Code + Codex 插件、希望任务完成有提醒、又不想装一堆第三方软件的开发者。整套方案只依赖 Windows 自带的 PowerShell 和 ntfy 的公开服务,不需要额外安装运行时。
这里有个前提要先说清楚。Codex 插件在 Windows 下会把会话过程写成 JSONL 文件,路径在C:\Users\<YourUser>\.codex\sessions下面。任务完成事件会以task_complete的形式落盘。我们要做的就是盯着这些文件,一旦发现新完成的任务,就调用通知脚本。这个思路比直接依赖 Codex 内置的 notify 钩子更稳,原因后面会讲。
另外,Codex 本身需要能正常调用模型。如果你还没配好 API 通道,可以先用 TaoToken 的统一 Key 把 Codex 跑通,再回来做通知链路。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,注册后在控制台生成 Key 即可。下面第二节会给出具体配置。
2. TaoToken 统一 Key 接入 Codex 的前置配置与 API 通道说明
在折腾通知之前,得先保证 Codex 在 VS Code 里能正常干活。Codex 插件读取的是用户目录下的配置文件,路径是C:\Users\<YourUser>\.codex\config.toml。如果你用的是 TaoToken 的统一 Key 通道,这个文件里需要写清楚模型提供方、Base URL 和 API Key。
TaoToken 的作用是把多家模型的调用收敛到一个 Key 上,Codex 只需要认一个 Base URL 和一个 Key,就能调用背后的模型。对 Codex 这种需要频繁请求的工具来说,省去了每个模型单独配 Key 的麻烦。API 地址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,直接作为 Base URL 使用。
下面是一份可以直接复制的config.toml片段。路径和字段名保持和 Codex 读取时一致,你只需要把<YOUR_TAOTOKEN_KEY>换成自己在控制台生成的 Key:
# C:\Users\<YourUser>\.codex\config.toml model = "claude-sonnet-4-5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" [model_providers.taotoken.headers] "X-Client" = "codex-vscode"这里有个细节要注意:env_key指定的是环境变量名,Codex 会从环境变量里读 Key,而不是把 Key 明文写在配置文件里。所以你还得在 Windows 里设置一个用户级环境变量。用 PowerShell 执行下面这行,把<YOUR_TAOTOKEN_KEY>替换成真实 Key:
[Environment]::SetEnvironmentVariable("TAOTOKEN_API_KEY", "<YOUR_TAOTOKEN_KEY>", "User")设置完之后要重启 VS Code,让新环境变量生效。验证方式是打开 VS Code 的集成终端,执行echo $env:TAOTOKEN_API_KEY,能打印出 Key 就说明环境变量读到了。
如果你更习惯用命令行方式管理 Key,也可以走 TaoToken 的 API Keys 页面生成和轮换 Key,地址是 https://taotoken.net/api-keys 。生成后同样填进上面的环境变量即可。
配置完成后,在 VS Code 里让 Codex 跑一个简单任务,比如「把当前文件里的 console.log 改成 logger.info」,观察它是否能正常返回结果。能正常返回,说明 API 通道通了,接下来才轮到通知链路。
有一点要提醒:Codex 的会话文件只有在任务真正执行时才会写入。如果你只是打开插件没跑任务,sessions目录可能是空的。所以做通知测试前,先确保至少跑过一次任务,让目录里有 JSONL 文件。
3. 可复制的 PowerShell 监听脚本与 ntfy 主题配置
这一节是整篇的核心。我们要写两个脚本:一个负责发通知,一个负责监听会话文件。先配 ntfy 主题,再写脚本。
ntfy 的使用方式很简单:你在手机 App 里订阅一个主题名,然后往https://ntfy.sh/<主题名>发一个 HTTP POST,手机就会收到推送。主题名相当于一个频道,建议用不容易被猜到的字符串,比如codex-notify-<随机串>。本文示例统一用<YOUR_NTFY_TOPIC>占位,你替换成自己的即可。
先在 Windows 上验证 ntfy 链路是否通。打开 PowerShell,执行:
curl.exe -v -d "hello from windows" https://ntfy.sh/<YOUR_NTFY_TOPIC>手机 App 里如果收到这条消息,说明推送通道没问题。收不到就检查主题名是否一致、手机是否联网、App 是否给了通知权限。
接下来创建通知脚本,路径放在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":"task_complete","summary":"test"}'手机收到通知、notify_log.txt里出现SEND: OK,就说明通知脚本正常。
然后是监听脚本,路径C:\Users\<YourUser>\.codex\codex_task_complete_watch.ps1。它的逻辑是轮询sessions目录下的 JSONL 文件,逐行解析,发现task_complete事件就提取摘要并调用通知脚本,同时用 turn_id 去重,避免重复推送:
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 }再写两个辅助脚本,一个启动、一个停止。启动脚本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)停止脚本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 }到这里,三个脚本加一个 ntfy 主题就齐了。启动脚本用-WindowStyle Hidden让 watcher 在后台跑,不占你的终端窗口。
4. 一次任务完成到手机收通知的完整验证
脚本写完了,得跑一遍完整链路,确认从 Codex 任务完成到手机震动这条路径是通的。
第一步,启动 watcher:
& "C:\Users\<YourUser>\.codex\codex_task_complete_watch_start.ps1"输出里会打印 PID,比如Watcher started. PID=12345。这时候去看codex_watch_log.txt,应该有一行Watcher started。
第二步,回到 VS Code,让 Codex 跑一个能明确结束的任务。比如选中一段代码,让它「给这个函数补上参数校验和单元测试」。任务执行过程中,Codex 会往sessions目录写 JSONL。任务结束时,会落一条task_complete事件。
第三步,观察 watcher 日志。任务完成后几秒内,codex_watch_log.txt里应该出现类似:
[2025-01-15 14:32:10] Tracking new file: C:\Users\<YourUser>\.codex\sessions\...\rollout-xxx.jsonl [2025-01-15 14:32:45] Notified turn_id=abc123第四步,看通知日志notify_log.txt,应该有SEND: OK。同时手机 ntfy App 收到一条标题为Codex Done的推送,正文是任务摘要。
第五步,确认去重文件tmp\codex_task_complete_seen.json里写入了这次的 turn_id。这样同一个任务不会重复推送。
如果这五步都过了,说明整条链路正常。之后你只要保持 watcher 在后台运行,每次 Codex 任务完成都会自动推送到手机。
这里补充一个实测细节:watcher 的轮询间隔是 1200 毫秒,任务完成后通常 1 到 3 秒内就能收到通知。如果你觉得延迟明显,可以把PollIntervalMs调小到 500,但会增加一点 CPU 占用。反过来,如果你机器负载高,调到 2000 也没问题,通知晚一两秒不影响使用。
还有一点,watcher 是按文件偏移量增量读取的,不会重复解析已经读过的行。所以即使sessions目录里积累了很多历史文件,启动时也不会把旧任务重新推一遍。这个设计对长期挂着 watcher 的场景很重要。
5. 常见报错排查:401、os error 206、local proxy failed 与 OAuth 问题
链路跑不通时,报错通常集中在几个地方。下面按真实遇到的错误逐个排查。
401 Unauthorized。这个多半是 TaoToken 的 Key 没配对。先确认环境变量TAOTOKEN_API_KEY是否设置成功,在 PowerShell 里执行echo $env:TAOTOKEN_API_KEY,如果为空,说明环境变量没生效,重启 VS Code 或重新登录 Windows 用户。如果环境变量有值但 Codex 还是 401,检查config.toml里的env_key字段是否和实际环境变量名一致,大小写敏感。还有一种情况是 Key 被轮换过,旧 Key 失效,去 https://taotoken.net/api-keys 重新生成一个填进去。
os error 206。这个错误在 Windows 上很典型,含义是「文件名或扩展名太长」。它出现在 Codex 内置 notify 钩子触发时,因为任务完成传给脚本的 JSON 参数可能非常长,超过了 Windows 命令行参数的长度限制。这也是本文不直接用内置 notify、改用外部 watcher 的原因。如果你在日志里看到after_agent hook failed ... hook_name=legacy_notify ... (os error 206),不用去改内置 notify,直接用第三节的 watcher 方案绕开即可。
local proxy failed。这个报错通常和网络配置有关。先确认config.toml里的base_url写的是https://taotoken.net/api,没有多余路径或参数。然后检查系统里是否设置了会干扰请求的环境变量,比如HTTP_PROXY、HTTPS_PROXY。如果有,临时清掉再试:
Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue清掉后重启 VS Code。如果公司网络有统一出口,按网络管理员给的配置来,不要自己乱设。
OAuth 相关报错。Codex 某些版本会走 OAuth 流程,如果报 OAuth 失败,先确认你用的是 API Key 模式而不是登录模式。在config.toml里确保model_provider指向的是taotoken,并且env_key对应的环境变量有值。OAuth 报错有时是因为本地缓存的凭证过期,删掉C:\Users\<YourUser>\.codex下的缓存文件(注意别删sessions和脚本),重新让 Codex 读取配置。
手机收不到通知。先单独测 ntfy:curl.exe -d "test" https://ntfy.sh/<YOUR_NTFY_TOPIC>。收不到就检查主题名、手机网络、App 通知权限。能收到但 Codex 任务完成时收不到,去看codex_watch_log.txt有没有Notified turn_id=,没有的话说明 watcher 没识别到task_complete,检查sessions目录里是否有新的 JSONL 文件,以及 watcher 是否在运行(看 PID 文件对应的进程是否存在)。
watcher 启动后立刻退出。多半是 PowerShell 执行策略拦了脚本。启动脚本里已经带了-ExecutionPolicy Bypass,如果还是不行,手动执行一次Set-ExecutionPolicy -Scope CurrentUser RemoteSigned,然后重新启动。
排查时记住一个顺序:先确认 Codex 本身能跑通(API 通道正常),再确认 ntfy 单独能收到,最后才看 watcher 有没有把两者串起来。按这个顺序,问题定位会快很多。
6. 把通知链路固定下来:日常使用与后续扩展
链路验证通过后,接下来是让它稳定地融入日常。最直接的做法是把启动脚本加到 Windows 的登录启动项里,这样每次开机 watcher 自动在后台跑,你不需要手动启动。方法是在shell:startup目录里放一个快捷方式,指向codex_task_complete_watch_start.ps1。
如果你不想开机自启,也可以在每个工作日的开始手动跑一次启动脚本,下班前跑停止脚本。PID 文件会记录进程号,停止脚本能准确杀掉对应的进程,不会误伤其他 PowerShell 窗口。
关于通知内容,目前正文是固定的Codex task done. Check VS Code.。如果你想让通知带上任务摘要,可以改通知脚本,把传入的$Json解析出来,提取summary字段作为正文。这样手机锁屏上就能直接看到任务结果的前几十个字,不用解锁进 VS Code。
再进一步,你可以按任务类型分流通知。比如在 watcher 里判断摘要里是否包含「test」「build」等关键词,给不同的 ntfy 主题发通知,手机上用不同主题区分优先级。ntfy 支持在请求头里设置Priority和Tags,高优先级的任务可以设成urgent,普通任务设成default。
如果你同时用多个 AI 编码工具,比如 Codex 和 Claude Code,可以把通知脚本抽成一个通用模块,不同工具完成时都调用它,只是传入的标题和摘要不同。这样手机上收到的通知格式统一,一眼就能看出是哪个工具跑完了。
长期来看,这套方案的价值在于把「等待」这件事从你的注意力里拿掉。任务在后台跑,完成时手机告诉你,你只需要在收到通知后回来处理结果。对于经常让 AI 跑批量任务的开发者,这个习惯能省下不少来回切换的时间。
最后留一个入口:如果你还没配好 Codex 的 API 通道,先去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 拿一个统一 Key,按第二节的config.toml配好,再回来搭通知链路。接入文档在 https://taotoken.net/doc ,里面有各客户端的配置示例。需要长期跑编码任务或 Agent 的,可以看看 Coding Plan,地址是 https://taotoken.net/coding-plan 。模型对话调试入口在 https://taotoken.net/chat ,控制台在 https://taotoken.net/console 。