TL;DR:AIエージェントは人の時間を解放するはずである。だが進捗が画面の中にしかないと、人は何度も振り返って確認することになる。この仕組みは、人が本当に必要な少数の瞬間を感覚ごとに割り当てる。画面は読むことと決めることに使い、音声はパソコンの近くにいるときに進捗を届け、スマホと Apple Watch の振動は状態を座席の外へ運ぶ。最初は Claude で作り、のちに Codex へ移した。いまはどちらも、一連の作業が完了したとき、あるいは人の判断が必要なときに、何が起きたかを自ら説明する。

長い作業を AIエージェントに渡したなら、人は別のことに向かえるはずである。実際には、別の注意の負担が残ることが多い。いつ終わるのか、止まっていないか、自分の判断を待っていないか。それが分からないので、しばらくするとウィンドウに戻ってしまう。

これでは AIエージェントは矛盾を抱える。人がつきっきりで操作しなくても働ける一方で、人の注意は進捗画面に縛られたままである。問題は「通知があるかどうか」だけではない。正しいタイミングで、そのとき人がいちばん気づきやすい経路から届くかどうかである。

私にとって、音声と振動を足したあとの AI との関わりは、視覚だけでは終わらない。部屋にいても画面を見ていなければ、耳が「タスク完了」や「判断が必要だ」を拾う。席を外せば、スマホの通知と手首の振動が同じ状態を手元へ運ぶ。画面に戻ってから、視覚で成果を読み、文脈を理解し、判断する。

先に Claude 向けの報告を作り、それを Codex へ移した。この記事は、手を動かしたい人向けに実装を残す。だが本当に共有したいのは、どの AIエージェント利用者にも使える考え方である。エージェントが長時間、人の代わりに働くなら、インタフェースも単一の画面から離れ、人の注意、居場所、感覚に合わせてよい。

音声と振動は、何を解くのか

音声通知が解く中心の問題は、人が自分から見に行かないと、AIエージェントの作業状態が分からないことである。進捗が視覚に完全依存している限り、人は作業ウィンドウから本当には離れにくい。

エージェントに、すべての手順を読み上げてほしいわけではない。本当に役立つ知らせは少数である。一連の作業が終わったこと、作業が続けられないこと、そしてシステムが人の承認や選択を待っていること。段階的な進捗は画面に残せばよい。人の行動を変えねばならないときだけ、音や振動が出る。

この協働では、三つの感覚が役割を分担する。

感覚の経路いちばん向いている中身利用者への効き方
視覚完成した成果、文脈、選択肢とリスク読む、比べる、決めるのに向く
聴覚完了や判断要請などの短い合図近くにいても画面を見ていないときに状態が分かる
触覚スマホや時計の振動席を外しているとき、周囲が騒がしいときにも気づける

音声と振動は、画面が届かない瞬間を補う。画面は、いまでも完全な内容を担う。人は見守り続けなくてよく、しかも本当に介入すべき節は逃さない。長い作業を走らせ、権限を待ち、途中の判断を求める AIエージェントであれば、この原則はどれにも当てはまる。Claude と Codex は、本稿が実際に通った二つの事例にすぎない。

AIエージェントの多感覚協働の図。画面は成果を読んで判断すること、音声は必要なときに注意を呼び戻すこと、スマホと時計の振動は知らせを座席の外へ運ぶことを担う
視覚、聴覚、触覚は、それぞれ異なる内容を引き受ける。音と振動は、人の行動を変える必要があるときだけ現れる。スマホは左右にスワイプできる。図をタップすると拡大する。

技術に詳しくなくても、導入キットを AI に渡せる

ターミナル、設定ファイル、hooks に慣れていなくても、後段のプログラムを一行ずつ理解する必要はない。整理した AI 向け導入タスク書をダウンロードし、この Mac を操作できる AI に渡して、環境の検知、計画、設定を任せればよい。

📦 ダウンロード:AIエージェント向け 音声・スマホ・時計通知の導入キット

ファイルを渡したあと、AI にはこう伝えれば足りる。

このファイルに従って設定を手伝ってほしい。まず検知と計画を出し、私の同意を得てから変更すること。キー、ログイン、システムの権限、または判断できない既存設定に当たったら、そこで止めて私本人が操作する。

この導入キットは、私のパソコン上のパスをそのまま写すよう AI に求めない。利用者が Claude Code、Codex、別のエージェントのどれを使っているかを先に見分け、その版に正式なイベント入口があるかを確認する。既存設定が衝突している、プラットフォームが一連の作業完了を信頼できる形で判断できない、あるいは定期ポーリングのような代替しかない場合、AI はそこで止めて説明せねばならない。「終わったように見せる」ために、黙って常駐プロセスを立ててはならない。

キットは安全の境界もタスクに書き込んでいる。Pushover の認証情報は利用者本人が入れる。判断待ちの通知は、人に代わって承認を押してはならない。試験はすべて無効化とドライランから始め、最後に音声、スマホ、時計を順に開ける。技術に詳しくない利用者にとって重要なのは、プログラムを一行ずつ指定することより、完了条件、停止条件、受け入れ方法をまとめて AI に渡すことである。

この通知は Claude から始まり、どう Codex へ移したか

開発の起点は Claude である。当時はまず Claude Code の Stop hook を task-done.sh に繋ぎ、そこから notify.sh へ渡して音声を再生し、Pushover を送った。task-done.sh は先に、明示のタスクマーカーを確認する。追跡中の一連作業がなければ、静かに終了する。この条件が、Claude の毎回の応答終了を「タスク完了」と言い切るのを防ぐ。

Claude 側の通知入口は、完了、人の判断待ち、実行失敗の三状態も残している。完了は Stop から入る。ワークフローが人の介入や継続不能を判断したときは、waitingfailed モードで同じ通知器を呼ぶ。Claude Code の公式ドキュメントは、Stop を各ターンの応答完了時に発火すると定義し、PermissionRequest などのイベントも提供する。hook はイベントの JSON 文脈を受け取る。だから実際のシステムは、自分側の「一連の作業」判定を足さねばならない。Stop を見ただけで引き渡し完了を宣言してはならない。参照は Claude Code Hooks reference である。

のちに作業環境へ Codex が入った。私は知らせ方を一から作り直さず、同じ通知の契約を移した。

  • 完了時は「どの AI が、何を終えたか」を説明する。
  • 判断待ちのときは「どの AI が、何を決めてほしいか」を説明する。
  • Mac は音声を再生し、Pushover は同じ状態を iPhone と Apple Watch へ送る。
  • 同じイベントの出力は最大一度であり、即座に無効化できる。

移すことは、hook をそのまま複製することではない。Claude と Codex では、イベント名、入力、完了の判断が違う。だから双方が、プラットフォーム側のアダプタをそれぞれ持つ。アダプタの仕事は、生のイベントを、一貫した完了・判断待ち・失敗へ変換することだけである。一度きりのロック、内容の伏せ字、音声、端末をまたぐ出力は、同じ設計に従う。いまの結果は「Codex にも独立した知らせがある」ではない。Claude と Codex が、同じ種類の、感知できる作業進捗レイヤーに乗っている、ということである。

まず二種類のイベントを定義する。停止のすべてを完了と呼ぶな

Claude と Codex の下層イベントは違う。だが主要な通知は、次の二類に分けた。

イベント人がすること通知の原則
判断が必要Claude か Codex に戻り、承認、拒否、または回答するすぐ知らせる。ただし同じ要請は一度だけ
タスク完了最終成果を見て、次を決める一連の作業の最終応答だけを知らせる

この区別は単純に見える。実装では、仕組み全体の中核である。

Codex の公式な上級設定は notify を提供し、現時点では agent-turn-complete を支える。イベント JSON には thread-idturn-id、作業ディレクトリ、利用者メッセージ、最後のアシスタントメッセージなどの欄が付く。公式の言い方は、一つの agent turn が終わった、である。「利用者が渡した一件の仕事が、まとまって終わった」ではない。両者をそのまま等号で結んではならない。詳細は Codex Advanced Configuration を見るとよい。

ツール手順、段階的な出力、別の作業ウィンドウでも、通知入口は呼ばれうる。だから完了通知は、最新メッセージが確かにメイン作業ウィンドウの phase=final であることを先に確認し、それから一度きりのロックへ進む。

判断の要請には、より正確な入口がある。Codex hooks の PermissionRequest は、システムが承認を必要とするときに走る。人をパソコンへ呼び戻す用途に向く。人に代わって自動承認してはならない。通知スクリプトの仕事は、人を呼ぶことだけである。判断は Codex の画面に残す。設定位置と信頼の仕組みは Codex Hooks のドキュメント を参照する。

全体構成:知らせは画面から出す。判断は人から離さない

全体の構成は次のとおりである。二つのプラットフォームが共有するのは、通知の意味と出力規則であり、同一のイベント設定ではない。

Claude と Codex の横断通知フロー。両プラットフォームがそれぞれイベントを受け、重複防止の条件を通したうえで、完了・判断待ち・失敗という共通の意味へ変換する。機微な内容を伏せたあと、macOS の音声と Pushover が iPhone と Apple Watch へ届ける。通知は人に代わって承認しない
Claude と Codex は異なるイベントアダプタを使い、最後は同じ通知の意味と安全原則へ収束する。スマホは左右にスワイプできる。図をタップすると拡大する。

安全原則は五つある。

  1. 明示の有効化ファイルがなければ、一切実行しない。
  2. Claude の完了通知には明示のタスクマーカーが要る。Codex の完了通知は、メイン作業ウィンドウの phase=final だけを受け付ける。
  3. Claude のタスクマーカーは一度だけ消費する。Codex の安定したイベントは、先に SQLite の一度きり主キーを取る。
  4. Codex の頻度サーキットブレーカーは、短時間のイベント量を抑える。Claude の音声キューは、複数ウィンドウの音声が重なるのを防ぐ。
  5. Pushover に緊急優先度は使わない。プログラムも再試行しない。

第五項は特に重要である。Pushover の Message APIpriority=2 を緊急通知と定義し、利用者が確認するまで繰り返し知らせる。それは本当の緊急事態向きであり、通常の AI 作業進捗には向かない。本稿は通常優先度の 0 を使う。

Pushover、iPhone、Apple Watch を用意する

必要なものは次である。

  • Mac 一台と、本体の hooks を実行できる Claude Code、Codex、またはその両方。
  • Python 3。本稿のスクリプトは Python 標準ライブラリだけを使う。
  • Pushover のアカウントと、iPhone に入れた Pushover アプリ。
  • Pushover の User Key と、自分で Application を作って得る API Token。
  • Apple Watch(任意)。

まず、使っているプラットフォームの版を確認する。

claude --version
codex --version

Pushover で自分の Application を作る。User Key と API Token は本体に置き、Git repository には書かない。

mkdir -p "$HOME/.codex/hooks" "$HOME/.codex/notification-state"
chmod 700 "$HOME/.codex/hooks" "$HOME/.codex/notification-state"

cat > "$HOME/.codex/pushover.env" <<'EOF'
PUSHOVER_TOKEN='あなたの Application API Token に置き換える'
PUSHOVER_USER='あなたの User Key に置き換える'
EOF

chmod 600 "$HOME/.codex/pushover.env"

上は本稿の Codex 例のパスを踏襲している。Claude と Codex が同じ Pushover 認証情報を読むなら、~/.config/ai-notify/pushover.env に置き、双方の通知器から指せばよい。要点は権限を 600 にし、版管理へコミットしないことである。

これは教程中の、一度きりのファイル作成コマンドである。実際に打つときは、ターミナル履歴、画面収録、画面共有がキーを晒さないかを確認する。このファイルの中身を AI に貼ってはならない。

続いて iPhone の「設定 → 通知 → Pushover」で通知を許可する。Apple Watch は初期状態で、iPhone アプリの通知設定をミラーできる。iPhone の Watch アプリから「通知」へ入り、Pushover を調整してもよい。Apple の説明は Change notification settings on Apple Watch にある。

注意点がある。Pushover API が成功を返しても、それはサーバがメッセージを受け付けたことだけを意味する。時計が振動するかどうかは、通知権限、集中モード、消音、時計を装着してロック解除しているか、その瞬間の iPhone と Apple Watch の状態にも左右される。

先に Claude を繋ぐ:明示のタスクマーカーで、毎回の Stop を完了扱いしない

Claude Code は、利用者層の ~/.claude/settings.json で hooks を設定できる。以下は Stop を完了判定スクリプトへ繋ぐ。

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/yourname/.claude/hooks/task-done.sh"
          }
        ]
      }
    ]
  }
}

Stop を、いきなり「タスクが完了した」音声へ直結してはならない。公式定義は、Claude が一度の応答を終えたことであり、利用者が頼んだ一連の作業が終わった保証ではない。いまの私のやり方は、作業開始時に ~/.claude/last-task.txt を作ることである。このファイルがあるときだけ、task-done.sh は通知し、マーカーを消す。

#!/bin/bash
set -u

TASK_FILE="$HOME/.claude/last-task.txt"
[ -s "$TASK_FILE" ] || exit 0

TASK_TITLE="$(head -n 1 "$TASK_FILE")"
rm -f "$TASK_FILE"
exec "$HOME/.claude/hooks/notify.sh" "$TASK_TITLE" done

追跡したい一連の作業を始めるとき、ワークフローは機密を含まない短い見出しをマーカーファイルへ書く。Claude が正常に終わり Stop が発火すると、マーカーは一度だけ消費される。通常の質疑応答にはマーカーがないので、スクリプトは静かに留まる。

notify.sh は Claude 側のプラットフォーム通知入口である。いまは三つのモードを受け付ける。

"$HOME/.claude/hooks/notify.sh" "何が完了したか" done
"$HOME/.claude/hooks/notify.sh" "何を決める必要があるか" waiting
"$HOME/.claude/hooks/notify.sh" "どこで止まったか" failed

このうち done は、上記の Stop 流れから入る。waitingfailed は、Claude のワークフローが人の介入や継続不能を確かめたときに呼ぶ。Claude の PermissionRequest を全自動で繋ぐなら、別途イベントアダプタを書き、標準入力から tool_nametool_input を解釈し、パス、キー、生のコマンドを伏せてから通知器へ渡す。承認や拒否の結果を hook へ書き戻してはならない。通知の仕事は人を探すことであり、決定は人が行う。

公開向けの手順では macOS 標準の say で足りる。私の実環境では、まず個人の声を試し、失敗したら Meijia に戻す。どの声を使うにせよ、notify.sh には一度きりのロックか音声キューが要る。複数の Claude ウィンドウが同時に再生するのを防ぐためである。Pushover のキーは前節の本体ファイルを使い、スクリプトや repository には書かない。

次に Codex を繋ぐ:一度だけ走る通知プログラムを作る

Codex は、Claude がすでに作った通知の意味を引き継ぐ。だがイベントアダプタは自分のものが要る。正式版は、イベント解釈、内容の要約、機微な内容の伏せ字、メインウィンドウ判定、SQLite の同時実行ロック、サーキットブレーカー、Pushover、音声を扱う。完了通知は last-assistant-message を要約する。判断通知は tool_input.description を優先して読み、説明がなければツール種別だけを知らせ、生のコマンドは送らない。以下は、自分で ~/.codex/hooks/codex_notify.py へまとめられる中核構造である。

#!/usr/bin/env python3
import hashlib, json, os, re, shlex, sqlite3, subprocess, sys, time
import urllib.parse, urllib.request
from pathlib import Path

HOME = Path.home()
STATE = HOME / ".codex/notification-state"
ENABLED = HOME / ".codex/notifications.enabled"
SESSIONS = HOME / ".codex/sessions"
ENV_FILE = HOME / ".codex/pushover.env"

MESSAGES = {
    "completion": "Codexの作業が完了した",
    "permission": "Codexが判断を求めている",
}

def h(text):
    return hashlib.sha256(text.encode()).hexdigest()[:32]

def read_event(mode, argv_json=None):
    raw = argv_json if mode == "completion" else sys.stdin.read()
    try:
        event = json.loads((raw or "{}").strip())
        return event if isinstance(event, dict) else {"value": event}
    except json.JSONDecodeError:
        return {"unparsed": raw}

def safe_text(value, limit=120):
    if not isinstance(value, str):
        return ""
    fence = re.escape("`" * 3)
    text = re.sub(f"{fence}.*?{fence}", " ", value, flags=re.S)
    text = re.sub(r"https?://\S+", "[リンク]", text)
    text = re.sub(r"(?i)Bearer\s+\S+", "Bearer [伏せ字]", text)
    text = re.sub(
        r"(?i)\b(api[_-]?key|token|password|secret)\b\s*[:=]\s*\S+",
        r"\1=[伏せ字]",
        text,
    )
    text = re.sub(r"/(?:Users|home)/\S+", "[ローカルパス]", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"

def event_detail(mode, event):
    if mode == "completion":
        raw = event.get("last-assistant-message", "")
        first = re.split(r"\n\s*\n", raw)[0] if isinstance(raw, str) else ""
        return safe_text(first) or "今回の依頼は最終結果まで到達した"
    tool = str(event.get("tool_name") or "")
    tool_input = event.get("tool_input")
    if isinstance(tool_input, dict):
        for key in ("description", "justification", "reason"):
            detail = safe_text(tool_input.get(key))
            if detail:
                return detail
    if tool.lower() in {"bash", "shell", "exec_command"}:
        return "ターミナルコマンドの実行を許可するか"
    if tool.lower() in {"apply_patch", "edit", "write"}:
        return "ファイルの変更を許可するか"
    return "Codexに戻り、確認待ちの操作を見る"

def final_gate(event):
    """現行バージョン依存。メインウィンドウの最新 phase=final のみ通す。"""
    thread_id = event.get("thread-id") or event.get("thread_id")
    if not thread_id:
        return False, None
    files = list(SESSIONS.rglob(f"*{thread_id}.jsonl"))
    if not files:
        return False, None
    path = max(files, key=lambda p: p.stat().st_mtime_ns)
    lines = path.read_text(errors="replace").splitlines()
    if not lines:
        return False, None
    try:
        meta = json.loads(lines[0]).get("payload", {})
    except json.JSONDecodeError:
        return False, None
    if meta.get("thread_source") == "subagent" or "subagent" in str(meta.get("source", {})):
        return False, None

    latest_activity_turn = None
    latest_assistant = None
    for line in reversed(lines[-10000:]):
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            continue
        if record.get("type") != "response_item":
            continue
        payload = record.get("payload", {})
        md = payload.get("internal_chat_message_metadata_passthrough", {})
        item_turn = md.get("turn_id") if isinstance(md, dict) else None
        latest_activity_turn = latest_activity_turn or item_turn
        if latest_assistant is None and payload.get("type") == "message" and payload.get("role") == "assistant":
            latest_assistant = (payload.get("phase"), item_turn)
        if latest_activity_turn and latest_assistant:
            break
    if not latest_assistant:
        return False, None
    phase, assistant_turn = latest_assistant
    allowed = phase == "final" and assistant_turn == latest_activity_turn and bool(assistant_turn)
    return allowed, assistant_turn

def db():
    STATE.mkdir(mode=0o700, parents=True, exist_ok=True)
    conn = sqlite3.connect(STATE / "events.sqlite3", isolation_level=None)
    conn.execute("PRAGMA busy_timeout=5000")
    conn.execute("CREATE TABLE IF NOT EXISTS events (event_key TEXT PRIMARY KEY, mode TEXT, created_at INTEGER)")
    return conn

def claim_once(mode, key):
    conn = db()
    now = int(time.time())
    conn.execute("BEGIN IMMEDIATE")
    try:
        if conn.execute("SELECT 1 FROM events WHERE event_key=?", (key,)).fetchone():
            conn.execute("COMMIT")
            return False
        # 簡易サーキットブレーカー。10分以内の同種イベントは最大3回。
        count = conn.execute(
            "SELECT COUNT(*) FROM events WHERE mode=? AND created_at>=?", (mode, now - 600)
        ).fetchone()[0]
        if count >= 3:
            conn.execute("COMMIT")
            return False
        conn.execute("INSERT INTO events VALUES (?, ?, ?)", (key, mode, now))
        conn.execute("COMMIT")
        return True
    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        conn.close()

def credentials():
    result = {}
    if not ENV_FILE.is_file():
        return result
    for raw in ENV_FILE.read_text().splitlines():
        if "=" not in raw or raw.lstrip().startswith("#"):
            continue
        key, value = raw.split("=", 1)
        if key in {"PUSHOVER_TOKEN", "PUSHOVER_USER"}:
            parsed = shlex.split(value.strip())
            result[key] = parsed[0] if parsed else ""
    return result

def push(message):
    c = credentials()
    if not c.get("PUSHOVER_TOKEN") or not c.get("PUSHOVER_USER"):
        return
    body = urllib.parse.urlencode({
        "token": c["PUSHOVER_TOKEN"],
        "user": c["PUSHOVER_USER"],
        "title": "Codex",
        "message": message,
        "priority": "0",
        "sound": "pushover",
    }).encode()
    req = urllib.request.Request(
        "https://api.pushover.net/1/messages.json", data=body, method="POST"
    )
    try:
        urllib.request.urlopen(req, timeout=6).read(4096)
    except Exception:
        pass  # 意図的に再試行しない。調査用のローカルログは別に書いてよい。

def main():
    mode = sys.argv[1]
    event = read_event(mode, sys.argv[2] if len(sys.argv) > 2 else None)
    if not ENABLED.is_file():
        return
    task_turn = event.get("turn-id") or event.get("turn_id")
    if mode == "completion":
        allowed, task_turn = final_gate(event)
        if not allowed:
            return
    canonical = json.dumps(event, sort_keys=True, ensure_ascii=False)
    thread = event.get("thread-id") or event.get("thread_id")
    if mode == "permission":
        session = event.get("session_id") or thread or "unknown"
        tool = event.get("tool_name") or "tool-unknown"
        tool_input = json.dumps(event.get("tool_input"), sort_keys=True, ensure_ascii=False)
        identity = f"{session}:{task_turn}:{tool}:{h(tool_input)}"
    else:
        identity = f"{thread}:{task_turn}" if thread and task_turn else canonical
    key = f"{mode}:{h(identity)}"
    if not claim_once(mode, key):
        return
    message = f"{MESSAGES[mode]}{event_detail(mode, event)}"
    push(message)
    subprocess.run(["/usr/bin/say", "-v", "Meijia", message], timeout=15, check=False)

if __name__ == "__main__":
    main()

保存したあと:

chmod 700 "$HOME/.codex/hooks/codex_notify.py"
touch "$HOME/.codex/notifications.enabled"
chmod 600 "$HOME/.codex/notifications.enabled"

呼び方を自分用に変えるなら、MESSAGES の二文だけを直す。event_detail() は「何が完了したか」「何を決める必要があるか」を補う。機微な内容の伏せ字と長さ制限は残す。イベント判定と重複排除の論理も、勝手に削ってはならない。

なぜ一時テキストではなく SQLite か

テキストファイルでも「この ID は見た」と記せる。だが二つの作業ウィンドウがほぼ同時に着くと、どちらも書き込む前に自分を第一号と判断しうる。SQLite の BEGIN IMMEDIATE と一意主キーは、「確認」と「専有」を同じ原子トランザクションへ入れられる。主キーを取ったプロセスだけが通知する。

一度きりのロック以外に、上流が段階ごとに別 ID を付ける可能性も見る必要がある。SQLite から見れば、それらはすべて新しいイベントである。だから第二層のサーキットブレーカーが要る。短時間の同種イベント数を制限する。正式利用では、完了と判断の通知にそれぞれ上限を置き、hook の振る舞いが変わっても境界が残るようにしている。

Codex の完了通知と判断通知を繋ぐ

完了通知は ~/.codex/config.tomlnotify から起動する。まず自分の絶対ホームを調べる。

echo "$HOME"

結果が /Users/yourname なら、設定は次である。

notify = ["/Users/yourname/.codex/hooks/agent-turn-complete.sh"]

もともと notify があるなら、そのまま上書きしてはならない。Codex ここは外部プログラムコマンドの一組であり、listener をいくらでも足せる一覧ではない。既存の入口から、新しいスクリプトを明示的に直列する必要がある。

agent-turn-complete.sh

#!/bin/bash
set -u
EVENT_JSON="${1-}"
if [ -z "$EVENT_JSON" ]; then
  EVENT_JSON="{}"
fi
exec /usr/bin/python3 "$HOME/.codex/hooks/codex_notify.py" completion "$EVENT_JSON"
chmod 700 "$HOME/.codex/hooks/agent-turn-complete.sh"

判断通知は ~/.codex/hooks.json に置く。

{
  "description": "Codex が承認を求めたとき、Mac の音声と Pushover で知らせる。",
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/yourname/.codex/hooks/permission-request.sh",
            "timeout": 30,
            "statusMessage": "承認の通知を送っている"
          }
        ]
      }
    ]
  }
}

permission-request.sh

#!/bin/bash
set -u
exec /usr/bin/python3 "$HOME/.codex/hooks/codex_notify.py" permission
chmod 700 "$HOME/.codex/hooks/permission-request.sh"

この hook は、承認も拒否も出力しない。知らせるだけであり、Codex は元の承認画面で人を待つ。

hooks を直したあと、Codex を開き直し、/hooks で読み込みと信頼状態を見る。公式ドキュメントは、hook 設定の変更後に再審査が必要になりうると注意している。ファイルがあるという理由だけで、効いていると思い込んではならない。

最初から音声、スマホ、時計を同時に試すな

いちばん安全な試験順は、層ごとに開けることである。

  1. まず Claude と Codex の通知スイッチを切り、すべてのイベントが静かなことを確認する。
  2. スクリプトは本体の記録だけを書き、音を出さず、Pushover も呼ばない。
  3. 同じ偽イベントを二度走らせ、SQLite ロックを取るのが一度目だけであることを確認する。
  4. Mac の音声だけを開け、一度しか話さないことを確認する。
  5. 最後に通常優先度の Pushover を一通送り、iPhone と Apple Watch の振る舞いを確認する。

正式スクリプトには、AI_NOTIFY_DRY_RUN=1AI_NOTIFY_NO_SOUND=1AI_NOTIFY_NO_PUSH=1 のような、プラットフォームをまたぐ環境変数を足せる。試験では一つずつ解除する。すべての出力端を一度に開けてはならない。

同じ正式文面を繰り返して試してもいけない。試験メッセージは「TEST」と明示し、スマホ上で試験を本物の作業状態と誤認させない。

最初から正しく置く、三つの設計判断

毎回の hook は、短命プロセスを一つだけ起動する

イベントが着くと、Claude か Codex の hook が、それぞれ通知プログラムを一度起動する。Pushover と音声の出力が終われば終了する。ここに daemon は立てない。launchd に周期実行も任せない。ネットワーク失敗時の自動 retry もしない。

この選択は、イベントと通知を一対一に保つ。Pushover が一時的に繋がらなくても、本体の記録に失敗結果は残せる。だが通知プログラムが、二度目の送信を自分で作ってはならない。

一連の作業かを先に判断し、それから一度きりのロックを取る

SQLite キーを turn-id だけに頼ってはならない。一件の作業は、ツール実行、段階進捗の報告、最終回答の生成で、異なる turn ID を持ちうる。完了通知は、先に三つの確認をする。

  • イベントはメイン作業ウィンドウから来ており、サブエージェントや他の背景作業ではない。
  • 最新アシスタントメッセージの phase は final であり、commentary ではない。
  • この final はいまの最新活動に属し、前の作業の残り記録ではない。

三つが通ってから、メイン作業ウィンドウ ID と最終タスクの turn ID で安定キーを作る。判断通知は session、turn、ツール名、ツール入力の要約でイベントキーを組み、同じ要請が一度しか専有されないようにする。

各出力には所有者を一人だけ置く

同じプラットフォームの完了通知と判断通知は、一本のプログラムを共有してよい。だが各イベントの入口は一条だけである。Claude と Codex は、それぞれイベントアダプタを持てばよい。音声や Pushover に入る前に、どのプラットフォームが今回のイベントを持っているかをはっきりさせる。新しい hook が、別の既存通知器をさらに呼んではならない。二つの背景ツールが同じイベントを同時に扱ってもならない。

既存の notify が他ツールに使われているなら、元の入口から明示的に直列する。見えない平行経路を別に立ててはならない。通知システムも他の自動化と同じで、発火条件、イベントキー、出力端、無効化の方法がはっきりしている必要がある。

Claude と Codex の完了判定には、どちらも版の境界がある

Claude Code の StopPermissionRequest、および Codex の PermissionRequest は、プラットフォームが提供する hook イベントである。Codex の agent-turn-complete も、公式が notify に渡すイベントである。だがこれらの名前は、両プラットフォームの payload、発火タイミング、完了の意味が完全に同じだということを意味しない。

Claude 側は明示のタスクマーカーを使い、毎回の Stop を一連の引き渡しと見なさない。Codex で commentaryfinal を分けるため、上の実装は ~/.codex/sessions/ の本体 JSONL 記録を追加で読む。

Codex 公式の hooks ドキュメントは、transcript の形式が安定インタフェースではなく、将来変わりうると明示している。だからこの final gate は、現行バージョン向けの実務上の防線であり、永遠に放置できる公式契約ではない。

Claude Code か Codex を上げるたびに、私はプラットフォーム別に回帰試験をするのを勧める。Claude では少なくとも、タスクマーカーのない Stop が静かなこと、マーカーがあるときは一度だけ完了すること、waiting の内容が何を決める必要があるかを説明できることを確認する。Codex では次を確認する。

  1. メインウィンドウの最終応答:一度通知するべきである。
  2. メインウィンドウの段階的 commentary:通知してはならない。
  3. 子作業やサブエージェントの final:主任務の完了を装ってはならない。
  4. 同じ final を二度入れる:二度目は SQLite が止めるべきである。

session 形式が変わったときの安全な初期値は「通知しない」であり、完了を推測することではない。知らせを一度逃すのは面倒である。だが誤って何度も通知するほうが危険である。

これは、私がエージェント運用とサイレント失敗で繰り返し扱ってきた問題とも重なる。システムは、自分で成功を宣言するだけでは足りない。通知は完了の証明ではない。条件検査を通った状態を、人のそばへ運ぶだけである。

設定が終わったあと、何をもって受け入れ合格とするか

私は七つの結果で、Claude と Codex を正式に開けてよいかを判断する。

  1. Claude に一連作業のマーカーがないとき、Stop は完了を宣言しない。
  2. Claude にタスクマーカーがあり完了したとき、Mac と Pushover がそれぞれ一度知らせ、文面は何が完了したかを説明する。
  3. Claude が waiting に入ったとき、文面は何を決める必要があるかを説明し、Claude の画面は人の判断を待ち続ける。
  4. Codex が承認を必要とするとき、Mac と Pushover がそれぞれ一度知らせ、文面は確認待ちの操作を説明する。Codex の画面は人の判断を待ち続ける。
  5. Codex のメイン作業ウィンドウが commentary を出しても完了を宣言せず、final になってから一度だけ知らせる。
  6. 同じイベントを通知プログラムへ再度入れたとき、一度きりのロックは二度目の専有を拒む。
  7. iPhone は Claude と Codex がそれぞれ出典を示した Pushover を受け、Apple Watch もミラー設定に従って表示または振動する。

最後に、両プラットフォームの無効化を別々に試し、一方を切っても他方へ影響せず、背景プロセスがメッセージを送り続けないことを確認する。これで通知機能は制御可能なまま残り、どちらかのプラットフォームを上げたあとの再検証も容易になる。

作業進捗を、聞こえる・感じられる状態にする

音声通知は、人がパソコンの近くにいても、注意が画面にない瞬間に仕える。Pushover と Apple Watch は、同じ作業状態を座席の外へ運ぶ。

両者を繋いだあと、私は注意を進捗アニメーションに縛らなくてよくなった。Claude と Codex は走り続けられる。権限や判断が必要なら、音声か時計が私を呼び戻す。最終結果ができてから、対応する画面に戻って検収する。

音と振動の一回ずつが、明確な状態に対応する。段階的な進捗は Claude か Codex に残し、判断が必要、続けられない、一連の成果ができたときにだけ、システムが能動的に知らせる。こうした通知は、作業進捗の見え方を増やし、異なるウィンドウを何度も確認することで起きる注意の切り替えを減らす。

この仕組みは、道具をまたぐ人と AI の協働境界を一本引く。AI は、人が画面を見ていなくても働き続けられる。人の介入が必要になったとき、あるいは引き渡しが終わったときに、一度の、はっきりして追跡できる通知で状態を戻す。Claude から始まり、Codex へ移したあとも元の仕組みを置き換えず、二つの作業環境が同じ報告の仕方に従うようにした。

本稿どおりにプログラムを書く予定がなくても、使っている AIエージェントを三つの問いで点検できる。一連の完了と、段階的な停止を見分けられるか。介入が必要なとき、何を決めるかを説明できるか。画面を見ていないとき、状態は他の感覚から自分を見つけられるか。どれか一問でも否なら、この記事の設計原則はまだ使える。

自分の AI 作業環境を組み立てているなら、本記事とサイトの AI テーマ頁 を併せて読むとよい。道具は変わり、イベント形式も変わる。だが音声と振動の用途は直接的である。作業進捗を画面から出し、人がいつでも気づける合図にする。