Codex には Claude Code の hooks 相当がないため、~/.codex/sessions を監視して assistant 応答を検知し、Slack Incoming Webhook に送る方式で実現します。
構成
- 監視スクリプト:
~/.codex/hooks/codex-watch-notify.sh - 機密設定:
~/.codex/slack-notify.env - 常駐:
~/Library/LaunchAgents/com.example.codex.slack-notify.plist - 状態保存:
~/.codex/slack-notify-state/
1. Slack Incoming Webhook を作成
-
Slack API で
Create New Appを押す -
From scratchを選択し、アプリ名と Workspace を指定 - 左メニュー
Incoming Webhooksを開く -
Activate Incoming WebhooksをOnにする -
Add New Webhookから通知先チャンネルを選んで許可 - 発行された
Webhook URLを控える(https://hooks.slack.com/services/...)
2. Webhook を環境ファイルに保存
~/.codex/slack-notify.env を作成:
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
権限を絞る:
chmod 600 ~/.codex/slack-notify.env
3. 監視スクリプトを配置
~/.codex/hooks/codex-watch-notify.sh:
#!/bin/bash
set -euo pipefail
SESSIONS_DIR="${HOME}/.codex/sessions"
STATE_DIR="${HOME}/.codex/slack-notify-state"
LAST_SESSION_FILE="${STATE_DIR}/last_session_path"
LAST_LINE_FILE="${STATE_DIR}/last_line"
LAST_SENT_KEY_FILE="${STATE_DIR}/last_sent_key"
POLL_INTERVAL_SEC="${POLL_INTERVAL_SEC:-5}"
QUIET_SEC="${QUIET_SEC:-90}"
ENV_FILE="${HOME}/.codex/slack-notify.env"
PENDING_MESSAGE_FILE="${STATE_DIR}/pending_message"
PENDING_TITLE_FILE="${STATE_DIR}/pending_title"
PENDING_EPOCH_FILE="${STATE_DIR}/pending_epoch"
mkdir -p "${STATE_DIR}"
WEBHOOK_URL=""
load_webhook_url() {
WEBHOOK_URL=""
if [ -f "${ENV_FILE}" ]; then
# shellcheck disable=SC1090
. "${ENV_FILE}"
WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
WEBHOOK_URL="$(printf '%s' "${WEBHOOK_URL}" | tr -d '\r')"
fi
}
session_tail_path() {
local latest_file=""
local latest_mtime=0
local file_path mtime
while IFS= read -r -d '' file_path; do
mtime="$(stat -f '%m' "${file_path}" 2>/dev/null || echo 0)"
if [ "${mtime}" -gt "${latest_mtime}" ]; then
latest_mtime="${mtime}"
latest_file="${file_path}"
fi
done < <(find "${SESSIONS_DIR}" -type f -name "*.jsonl" -print0 2>/dev/null)
printf '%s\n' "${latest_file}"
}
read_state() {
LAST_SESSION_PATH=""
LAST_LINE=0
LAST_SENT_KEY=""
PENDING_MESSAGE=""
PENDING_TITLE=""
PENDING_EPOCH=0
[ -f "${LAST_SESSION_FILE}" ] && LAST_SESSION_PATH="$(cat "${LAST_SESSION_FILE}" 2>/dev/null || true)"
[ -f "${LAST_LINE_FILE}" ] && LAST_LINE="$(cat "${LAST_LINE_FILE}" 2>/dev/null || echo 0)"
[ -f "${LAST_SENT_KEY_FILE}" ] && LAST_SENT_KEY="$(cat "${LAST_SENT_KEY_FILE}" 2>/dev/null || true)"
[ -f "${PENDING_MESSAGE_FILE}" ] && PENDING_MESSAGE="$(cat "${PENDING_MESSAGE_FILE}" 2>/dev/null || true)"
[ -f "${PENDING_TITLE_FILE}" ] && PENDING_TITLE="$(cat "${PENDING_TITLE_FILE}" 2>/dev/null || true)"
[ -f "${PENDING_EPOCH_FILE}" ] && PENDING_EPOCH="$(cat "${PENDING_EPOCH_FILE}" 2>/dev/null || echo 0)"
}
write_state() {
printf '%s\n' "${CURRENT_SESSION_PATH}" > "${LAST_SESSION_FILE}"
printf '%s\n' "${CURRENT_LINE_COUNT}" > "${LAST_LINE_FILE}"
printf '%s\n' "${LAST_SENT_KEY}" > "${LAST_SENT_KEY_FILE}"
printf '%s\n' "${PENDING_MESSAGE}" > "${PENDING_MESSAGE_FILE}"
printf '%s\n' "${PENDING_TITLE}" > "${PENDING_TITLE_FILE}"
printf '%s\n' "${PENDING_EPOCH}" > "${PENDING_EPOCH_FILE}"
}
notify_slack() {
local message="$1"
local title="$2"
[ -z "${WEBHOOK_URL}" ] && return 0
local ts payload
ts="$(date "+%Y-%m-%d %H:%M:%S")"
payload="$(
jq -n \
--arg text "🔔 ${title}" \
--arg msg "${message}" \
--arg time "${ts}" \
'{
text: $text,
attachments: [{
color: "good",
fields: [
{ title: "Message", value: $msg, short: false },
{ title: "Time", value: $time, short: true }
]
}]
}'
)"
curl -sS -X POST \
-H "Content-Type: application/json" \
-d "${payload}" \
"${WEBHOOK_URL}" >/dev/null 2>&1 || true
}
queue_notification() {
local message="$1"
local title="$2"
PENDING_MESSAGE="${message}"
PENDING_TITLE="${title}"
PENDING_EPOCH="$(date +%s)"
}
flush_notification_if_quiet() {
[ -z "${PENDING_MESSAGE}" ] && return 0
local now
now="$(date +%s)"
if [ $((now - PENDING_EPOCH)) -ge "${QUIET_SEC}" ]; then
notify_slack "${PENDING_MESSAGE}" "${PENDING_TITLE:-Codex: response ready}"
PENDING_MESSAGE=""
PENDING_TITLE=""
PENDING_EPOCH=0
fi
}
extract_notifications() {
local file_path="$1"
local start_line="$2"
tail -n +"$((start_line + 1))" "${file_path}" 2>/dev/null | jq -cr '
select(.type == "response_item")
| select(.payload.type == "message")
| select(.payload.role == "assistant")
| .payload as $p
| ($p.content // []) as $content
| ($content[]? | select(.type == "output_text") | .text) as $text
| select($text != null and $text != "")
| { ts: (.timestamp // ""), text: $text }
' 2>/dev/null
}
while true; do
load_webhook_url
read_state
CURRENT_SESSION_PATH="$(session_tail_path)"
if [ -z "${CURRENT_SESSION_PATH}" ] || [ ! -f "${CURRENT_SESSION_PATH}" ]; then
sleep "${POLL_INTERVAL_SEC}"
continue
fi
CURRENT_LINE_COUNT="$(wc -l < "${CURRENT_SESSION_PATH}" | tr -d ' ')"
START_LINE="${LAST_LINE}"
[ "${CURRENT_SESSION_PATH}" != "${LAST_SESSION_PATH}" ] && START_LINE=0
[ "${CURRENT_LINE_COUNT}" -lt "${START_LINE}" ] && START_LINE=0
if [ "${CURRENT_LINE_COUNT}" -gt "${START_LINE}" ]; then
while IFS= read -r row; do
[ -z "${row}" ] && continue
ts="$(echo "${row}" | jq -r '.ts')"
text="$(echo "${row}" | jq -r '.text')"
first_line="$(printf '%s' "${text}" | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g' | cut -c1-180)"
notify_key="${CURRENT_SESSION_PATH}|${ts}|${first_line}"
[ "${notify_key}" = "${LAST_SENT_KEY}" ] && continue
queue_notification "${first_line}" "Codex: response ready"
LAST_SENT_KEY="${notify_key}"
done < <(extract_notifications "${CURRENT_SESSION_PATH}" "${START_LINE}")
fi
flush_notification_if_quiet
write_state
sleep "${POLL_INTERVAL_SEC}"
done
実行権限:
chmod +x ~/.codex/hooks/codex-watch-notify.sh
4. launchd で常駐
~/Library/LaunchAgents/com.example.codex.slack-notify.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.codex.slack-notify</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/<your_username>/.codex/hooks/codex-watch-notify.sh</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>WorkingDirectory</key><string>/Users/<your_username></string>
<key>StandardOutPath</key><string>/Users/<your_username>/.codex/log/codex-watch-notify.log</string>
<key>StandardErrorPath</key><string>/Users/<your_username>/.codex/log/codex-watch-notify.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>POLL_INTERVAL_SEC</key><string>5</string>
<key>QUIET_SEC</key><string>90</string>
</dict>
</dict>
</plist>
起動:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.codex.slack-notify.plist
launchctl kickstart -k gui/$(id -u)/com.example.codex.slack-notify
停止:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.codex.slack-notify.plist
状態確認:
launchctl print gui/$(id -u)/com.example.codex.slack-notify
5. 連投抑制の考え方
-
POLL_INTERVAL_SEC=5: 5秒ごとにセッションを監視 -
QUIET_SEC=90: 最終応答から90秒静止した時点で1件だけ通知 -
SLACK_WEBHOOK_URL: ループごとに再読込(slack-notify.env更新を再起動なしで反映)
このため、短時間に何度も応答が返る場合でも Slack には最後の要約1件だけが届きます。
6. 注意点
- Webhook URL を Git 管理ファイルに含めない
- セッション先頭テキストを送る設計なので、機密情報の取り扱いに注意
- Codex 側 JSON 形式変更時は
jq抽出条件の見直しが必要