🔔

WindowsでのCodex CLIの通知をカスタマイズしてみた

に公開

はじめに

Codex CLIで重い処理を投げたあと、完了を“音と通知”で気づきたい ——でもたいてい~/.codex/config.tomlnotify 設定例は macOS向け ばかり。
「Windowsなら通知センター(アクションセンター)があるし、どうにかならんの?」というところから始めて、 + 抜粋メッセージ + デスクトップ通知 を実現してみた。

用語メモ:Codexの「1回の指示→応答までのまとまり」を何と呼ぶ?
公式ドキュメントでは一般に request / response(あるいは task)で語られることが多い。ここでは読みやすさを優先してタスクと呼ぶ。

ゴール

  • Codexタスク完了時に
    1. 通知音を鳴らす
    2. 応答内容の抜粋Windows通知として表示する
  • 依存なし(標準 .NET / WinForms)で実装
  • 通知方式を切替可能(標準バルーン通知 / カスタムポップアップ)

前提

  • OS: Windows 10(動作確認済)/Windows 11(互換挙動のため基本的に動く想定)
  • PowerShell: 5.1 以上(7.xでもOK)
  • Codex CLI が ~/.codex/config.tomlnotify 経由で外部スクリプトを呼べる設定

設定: ~/.codex/config.toml

Codex CLI から PowerShell スクリプトを叩く設定はこんな感じ。

notify = [
  "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
  "-File", "C:\\Users\\<USERNAME>\\.codex\\notify.ps1",
  "-PreferPopup", "-UseExcerpt", "-EnableLogging" 
]
  • -NoProfile : 実行を速く・安定させるため
  • -ExecutionPolicy Bypass : 署名していないスクリプトを実行するため必要
  • 以下は notify.ps1 用のオプションで全て省略可能
    • -PreferPopup : カスタムポップアップを使う場合に指定する
    • -UseExcerpt : Codexの最終回答を抽出してメッセージ本文に出力する場合に指定する
    • -EnableLogging : Codexの現セッションのやり取り内容をログに出力する場合に指定する

完成版スクリプト: notify.ps1

  • 抜粋生成(Codexの最終回答のみを抜粋)
  • 通知音(任意の notification.wav)
  • 通知方式の切替(バルーン通知 or 集中モードでも有効なカスタムポップアップ)

※ 保存時は必ず「UTF-8 (BOM付き)」にすること(理由は後述)

[CmdletBinding()]
param(
    [string]$Message = '',
    [switch]$PreferPopup,
    [switch]$UseExcerpt,
    [switch]$EnableLogging
)

# ============ 設定値 ============
$title  = 'Codex からの通知'                  # 通知タイトル
$defaultBody = 'タスクが終了しました。'
$defaultPreferFocusAssistBypass = $false  # 集中モード無視のカスタムポップアップ既定値
$defaultUseCompromiseExcerpt = $false     # 抽出結果使用の既定値

$preferFocusAssistBypass = if ($PreferPopup.IsPresent) { $true } else { $defaultPreferFocusAssistBypass }
$useCompromiseExcerpt = if ($UseExcerpt.IsPresent) { $true } else { $defaultUseCompromiseExcerpt }
$notifyLogEnabled = $EnableLogging.IsPresent
function Extract-LastAssistantMessageByPattern {
    param([string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) {
        return $null
    }

    $pattern = "(?is)last[-_ ]assistant[-_ ]message\s*[:=]\s*[""'']?([^""'}\r\n]+)"
    $match = [regex]::Match($Text, $pattern)
    if ($match.Success) {
        $clean = $match.Groups[1].Value
        $clean = $clean.Trim()
        $clean = $clean.Trim('"', "'", '{', '}', '[', ']', ' ')
        return $clean
    }

    return $null
}

function Get-LastAssistantMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Message
    )

    $trimmed = $Message.Trim()
    $parsed = $null
    try {
        $parsed = $trimmed | ConvertFrom-Json

        # 1) 最優先キー
        $msg = $parsed.'last-assistant-message'
        if ([string]::IsNullOrWhiteSpace($msg)) {
            # 2) スネークケース
            $msg = $parsed.'last_assistant_message'
        }

        if ([string]::IsNullOrWhiteSpace($msg) -and $parsed.'input-messages') {
            # 3) input-messages の末尾
            $msg = $parsed.'input-messages' |
                Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } |
                Select-Object -Last 1
        }

        if ([string]::IsNullOrWhiteSpace($msg)) {
            $msg = Extract-LastAssistantMessageByPattern -Text $Message
            if ([string]::IsNullOrWhiteSpace($msg)) {
                throw "last-assistant-message が見つかりませんでした"
            }
        }

        return $msg
    } catch {
        $fallback = Extract-LastAssistantMessageByPattern -Text $Message
        if (-not [string]::IsNullOrWhiteSpace($fallback)) {
            return $fallback
        }
        throw "JSON解析に失敗しました: $($_.Exception.Message)"
    }
}

$logRoot = $null
$debugLogPath = $null
$utf8NoBom = $null

if ($notifyLogEnabled) {
    $logRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
    $debugLogPath = Join-Path $logRoot 'notify-debug.log'
    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
}

function Write-NotifyLog {
    param([string]$Entry)
    if (-not $notifyLogEnabled) {
        return
    }
    if ([string]::IsNullOrEmpty($Entry)) {
        return
    }
    try {
        [System.IO.File]::AppendAllText($debugLogPath, $Entry, $utf8NoBom)
    } catch {
        # ログ出力失敗時は通知処理を継続
    }
}

if ($notifyLogEnabled) {
    $timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffzzz'
    $rawMessageForLog = if ([string]::IsNullOrEmpty($Message)) { '<empty>' } else { $Message }
    $messageType = if ($null -eq $Message) { '<null>' } else { $Message.GetType().FullName }
    $logEntry = "[{0}] RawMessageType: {1} RawMessage: {2}`n" -f $timestamp, $messageType, $rawMessageForLog
    Write-NotifyLog $logEntry
}

$parsedBody = $null
try {
    $parsedBody = Get-LastAssistantMessage -Message $Message
} catch {
    $errorMessage = if ($null -ne $_ -and $_.Exception) { $_.Exception.Message } else { 'unknown error' }
    if ($notifyLogEnabled) {
        $errorLogEntry = "[{0}] ParseError: {1}`n" -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffzzz'), $errorMessage
        Write-NotifyLog $errorLogEntry
    }
    $parsedBody = $null
}
if ($notifyLogEnabled) {
    $loggedParsedBody = if ([string]::IsNullOrWhiteSpace($parsedBody)) { '<null-or-empty>' } else { $parsedBody }
    $parsedLogEntry = "[{0}] ParsedBody: {1}`n" -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffzzz'), $loggedParsedBody
    Write-NotifyLog $parsedLogEntry
}
$notificationBody = if ($useCompromiseExcerpt) {
    if (-not [string]::IsNullOrWhiteSpace($parsedBody)) {
        $parsedBody
    } elseif (-not [string]::IsNullOrWhiteSpace($Message)) {
        $Message.Substring([Math]::Max(0, $Message.Length - 120))
    } else {
        $defaultBody
    }
} else {
    $defaultBody
}

if ([string]::IsNullOrWhiteSpace($notificationBody)) {
    $notificationBody = $defaultBody
}

if ($notifyLogEnabled) {
    $loggedBody = if ([string]::IsNullOrWhiteSpace($notificationBody)) { '<empty>' } else { $notificationBody }
    $notificationLogEntry = "[{0}] NotificationBody: {1}`n" -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffzzz'), $loggedBody
    Write-NotifyLog $notificationLogEntry
}

function Show-CodexBalloon([string]$titleText, [string]$bodyText, [int]$timeoutMs, [bool]$suppressSound = $false) {
    Add-Type -AssemblyName System.Windows.Forms | Out-Null
    Add-Type -AssemblyName System.Drawing | Out-Null

    if (-not ([System.Management.Automation.PSTypeName]'CodexBalloonApplicationContext').Type) {
        Add-Type -Language CSharp -WarningAction SilentlyContinue -ReferencedAssemblies @('System.Windows.Forms', 'System.Drawing') -TypeDefinition @"
using System;
using System.Drawing;
using System.Windows.Forms;

public sealed class CodexBalloonApplicationContext : ApplicationContext
{
    private readonly NotifyIcon notifyIcon;
    private readonly Timer lifetimeTimer;
    private bool cleaned;

    public CodexBalloonApplicationContext(string title, string text, int timeout, bool suppressSound)
    {
        notifyIcon = new NotifyIcon
        {
            Icon = SystemIcons.Information,
            BalloonTipIcon = suppressSound ? ToolTipIcon.None : ToolTipIcon.Info,
            BalloonTipTitle = string.IsNullOrWhiteSpace(title) ? "Codex" : title,
            BalloonTipText = string.IsNullOrWhiteSpace(text) ? "Codex からの通知" : text,
            Visible = true,
            Text = BuildTrayText(text)
        };

        lifetimeTimer = new Timer
        {
            Interval = Math.Max(timeout + 1000, 3000)
        };

        lifetimeTimer.Tick += (_, __) => Cleanup();
        notifyIcon.BalloonTipClosed += (_, __) => Cleanup();
        notifyIcon.BalloonTipClicked += (_, __) => Cleanup();

        lifetimeTimer.Start();
        notifyIcon.ShowBalloonTip(timeout);
    }

    private static string BuildTrayText(string text)
    {
        if (string.IsNullOrWhiteSpace(text))
        {
            return "Codex";
        }

        var trimmed = text.Trim();
        return trimmed.Length <= 63 ? trimmed : trimmed.Substring(0, 60) + "...";
    }

    private void Cleanup()
    {
        if (cleaned)
        {
            return;
        }

        cleaned = true;
        lifetimeTimer.Stop();
        lifetimeTimer.Dispose();
        notifyIcon.Visible = false;
        notifyIcon.Dispose();
        ExitThread();
    }
}
"@ | Out-Null
    }

    [System.Windows.Forms.Application]::EnableVisualStyles()
    [System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
    [System.Windows.Forms.Application]::Run([CodexBalloonApplicationContext]::new($titleText, $bodyText, $timeoutMs, $suppressSound))
}

function Show-CodexPopup([string]$titleText, [string]$bodyText, [int]$timeoutMs) {
    Add-Type -AssemblyName System.Windows.Forms | Out-Null
    Add-Type -AssemblyName System.Drawing | Out-Null

    if (-not ([System.Management.Automation.PSTypeName]'CodexPopupApplicationContext').Type) {
        Add-Type -Language CSharp -WarningAction SilentlyContinue -ReferencedAssemblies @('System.Windows.Forms', 'System.Drawing') -TypeDefinition @"
using System;
using System.Drawing;
using System.Windows.Forms;

public sealed class CodexPopupApplicationContext : ApplicationContext
{
    private readonly Form toast;
    private readonly Timer lifetimeTimer;
    private bool cleaned;

    public CodexPopupApplicationContext(string title, string text, int timeout)
    {
        toast = new Form
        {
            FormBorderStyle = FormBorderStyle.None,
            StartPosition = FormStartPosition.Manual,
            ShowInTaskbar = false,
            TopMost = true,
            BackColor = Color.FromArgb(32, 32, 32),
            ForeColor = Color.White,
            Opacity = 0.95,
            AutoSize = true,
            AutoSizeMode = AutoSizeMode.GrowAndShrink,
            Padding = new Padding(16)
        };

        // 初期描画位置は画面外に逃がしてちらつきを抑制
        toast.Location = new Point(-10000, -10000);

        var panel = new TableLayoutPanel
        {
            Dock = DockStyle.Fill,
            ColumnCount = 1,
            RowCount = 2,
            BackColor = Color.FromArgb(32, 32, 32),
            AutoSize = true,
            AutoSizeMode = AutoSizeMode.GrowAndShrink,
            Margin = new Padding(0)
        };

        panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
        panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
        panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));

        var titleLabel = new Label
        {
            AutoSize = true,
            Font = new Font(SystemFonts.CaptionFont.FontFamily, 11f, FontStyle.Bold),
            ForeColor = Color.White,
            Text = string.IsNullOrWhiteSpace(title) ? "Codex" : title,
            UseCompatibleTextRendering = true
        };

        var bodyLabel = new Label
        {
            AutoSize = true,
            Font = new Font(SystemFonts.MessageBoxFont.FontFamily, 9f, FontStyle.Regular),
            ForeColor = Color.White,
            MaximumSize = new Size(360, 0),
            Text = string.IsNullOrWhiteSpace(text) ? "Codex からの通知" : text,
            UseCompatibleTextRendering = true
        };

        panel.Controls.Add(titleLabel, 0, 0);
        panel.Controls.Add(bodyLabel, 0, 1);
        panel.Padding = new Padding(0, 0, 0, 4);
        toast.Controls.Add(panel);

        lifetimeTimer = new Timer
        {
            Interval = Math.Max(timeout + 1000, 4000)
        };

        lifetimeTimer.Tick += (_, __) => Cleanup();
        toast.Click += (_, __) => Cleanup();
        panel.Click += (_, __) => Cleanup();
        titleLabel.Click += (_, __) => Cleanup();
        bodyLabel.Click += (_, __) => Cleanup();
        toast.Deactivate += (_, __) => Cleanup();

        toast.Shown += (_, __) => PositionToast();

        lifetimeTimer.Start();
        toast.Show();
    }

    private void PositionToast()
    {
        var area = Screen.PrimaryScreen.WorkingArea;
        var width = toast.Width;
        var height = toast.Height;
        toast.Location = new Point(area.Right - width - 24, area.Bottom - height - 24);
    }

    private void Cleanup()
    {
        if (cleaned)
        {
            return;
        }

        cleaned = true;
        lifetimeTimer.Stop();
        lifetimeTimer.Dispose();
        toast.Close();
        toast.Dispose();
        ExitThread();
    }
}
"@ | Out-Null
    }

    [System.Windows.Forms.Application]::EnableVisualStyles()
    [System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
    [System.Windows.Forms.Application]::Run([CodexPopupApplicationContext]::new($titleText, $bodyText, $timeoutMs))
}

# 2) 通知音の再生
$wavPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'notification.wav' } else { $null }
$hasCustomSound = $false
if ($wavPath -and (Test-Path $wavPath)) {
    try {
        $player = New-Object System.Media.SoundPlayer $wavPath
        $player.Play()
        $hasCustomSound = $true
    } catch {
        Write-Warning "通知音ファイルの再生に失敗しました: $($_.Exception.Message)"
    }
}

# 3) デスクトップ通知
try {
    if ($preferFocusAssistBypass) {
        Show-CodexPopup -titleText $title -bodyText $notificationBody -timeoutMs 5000
    } else {
        Show-CodexBalloon -titleText $title -bodyText $notificationBody -timeoutMs 5000 -suppressSound:$hasCustomSound
    }
} catch {
    if ($preferFocusAssistBypass) {
        try {
            Show-CodexBalloon -titleText $title -bodyText $notificationBody -timeoutMs 5000 -suppressSound:$hasCustomSound
            return
        } catch {
            # バルーン通知も失敗したらフォールバックへ
        }
    }

    Add-Type -AssemblyName System.Windows.Forms | Out-Null
    Add-Type -AssemblyName System.Drawing | Out-Null

    $fallback = New-Object System.Windows.Forms.NotifyIcon
    try {
        $fallback.Icon = [System.Drawing.SystemIcons]::Information
        $fallback.BalloonTipIcon = if ($hasCustomSound) { [System.Windows.Forms.ToolTipIcon]::None } else { [System.Windows.Forms.ToolTipIcon]::Info }
        $fallback.BalloonTipTitle = $title
        $fallback.BalloonTipText = $notificationBody
        $fallback.Visible = $true
        $fallback.ShowBalloonTip(5000)
        Start-Sleep -Seconds 6
    } finally {
        $fallback.Dispose()
    }
}

使い方

  1. C:\Users\<USERNAME>\.codex\notify.ps1 に上記内容で保存する。
    • 保存形式は「UTF-8 (BOM付き)」 にする(VSCodeなら右下のエンコーディングから変更可能)
  2. 同じディレクトリに任意の notification.wav を置く(なくてもOK)
  3. ~/.codex/config.toml に前述の notify 行を追加(または既存を差し替え)
  4. Explorerのアドレス欄に「powershell」と入れるとPowerShellが起動するので、そこで
    powershell -File notify.ps1
    
    とか
    powershell -File notify.ps1 -UseExcerpt -Message '{"last-assistant-message":"最終回答のダミーです"}'
    
    と実行して単体テストできる
  5. あとはCodexタスクを実行 → 完了時に通知が出る

仕組みのポイント

  • Codex CLI から渡される標準入力(内容は JSON オブジェクトだが型は文字列)を $Message として受け取り、最終回答である last-assistant-message をパースして本文候補として抽出
  • $Message の文字列には Codex 現行セッションでのやり取り全てが含まれているが、日本語でやり取りをしていると文字コード混在によりほぼ確実に JSON パースが失敗する。その場合は強制的に文字列分割して最終回答を強引に抽出しているのでご留意いただきたい。
  • 通知は2系統:
    1. バルーン通知NotifyIcon.ShowBalloonTip() 。追加インストール不要で手軽に使える。
      • Windows 10/11 では通知センター経由で“トーストっぽい”見た目になる。集中モードがオンになっていると表示されないので注意。
      • 表示時に通知センターの既定通知音が鳴るため、カスタム通知音を設定していると重なってしまう。カスタム通知音を併用したい場合は通知センターの設定から既定通知音をオフにする必要がある。
      • デフォルトではこちらの表示が有効になっている。
    2. カスタムポップアップ:小さな自作トースト。
      • 集中モード中でも有効。
      • カスタム通知音を設定しないと無音で表示されるので、こっちを使うならカスタム通知音は必須かと。
      • こっちを使う時は config.tomlnotify 設定に -PreferPopup オプションを指定すること。
  • 通知本文も2系統:
    1. 固定文言: 通知本文には「タスクが終了しました。」が常に固定で表示される。
      • デフォルトはこちら。
    2. 抜粋回答: Codexとのタスクの最終回答が抜粋されて表示される。
      • Codexとのやり取りが日本語だと、JSON パースが上手くいかずに綺麗なテキストとして抽出できない可能性があるので注意。
      • こっちを使う時は config.tomlnotify 設定に -UseExcerpt オプションを指定すること。
  • ログ出力用に config.tomlnotify 設定に -EnableLogging オプションを指定することができる。ログ出力が有効になると、 notify.ps1 と同じディレクトリ下に notify-debug.log が出力される。Codex とのタスクが完了する毎にそれまでの同セッションのやり取りがすべてログとして出力されるので、やり取りの履歴を確認したい時などに利用できる。ファイルサイズが肥大化しやすいのでログ出力を使用する場合は定期的にクリーンアップすること。

外観

実際に表示される通知の外観は下記の通り。これらの通知がスクリーンの右下に表示される。

  • 既定のバルーン通知
    規定バルーン通知
  • カスタムポップアップ
    カスタムポップアップ

注意点とTips

なぜ「UTF-8 (BOM付き)」が必要か?

  • PowerShell 5.1BOMなしUTF-8デフォルトではUTF-8と認識しない(システム既定コードページ=日本語環境なら Shift_JIS で解釈 → 文字化け)。
  • BOM付きUTF-8 なら 5.1 でも確実にUTF-8扱い。
  • PowerShell 7.x はBOMなしUTF-8がデフォルトだが、BOM付きでも互換する。

通知音は WAVのみ

  • .NET の System.Media.SoundPlayer はWAV専用。MP3等は再生不可。
  • 変換が必要なら ffmpeg 等でWAVにして配置すること。

発展形:BurntToastで“純正風”トースト通知にする

見た目をWindows標準のトーストへ寄せたいなら、Install-Module BurntToast でモジュール導入 → New-BurntToastNotification を使うという手もあるかもしれない。

ただ、外部依存が増えるので、本記事ではお手軽な依存ゼロ版を紹介している。

まとめ

  • Codexの通知は ~/.codex/config.tomlnotify で任意のコマンドを叩ける。
  • PowerShellだけで、 + 抜粋 + デスクトップ通知まで実装可能。
  • UTF-8 (BOM付き)WAV限定フォーカスアシスト周りの挙動にだけ注意すれば、Windowsでも快適な通知体験が作れる。

通知音をドラクエのレベルアップ音にしてみたら、Codexとのやり取り時のテンションが上がりましたw

Discussion