0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Codex 作業完了を Slack 通知する

0
Last updated at Posted at 2026-02-11

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 を作成

  1. Slack APICreate New App を押す
  2. From scratch を選択し、アプリ名と Workspace を指定
  3. 左メニュー Incoming Webhooks を開く
  4. Activate Incoming WebhooksOn にする
  5. Add New Webhook から通知先チャンネルを選んで許可
  6. 発行された 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 抽出条件の見直しが必要
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?