← 一覧に戻る

Permission通知が2回目以降来ない問題の調査報告

2026年4月5日 00:22 更新
MD から自動変換されたページです。内容について質問があれば右下の ? ボタンからどうぞ。

結論

最有力仮説: tmux.py send が受信側セッションの状態を確認せずに送信(fire-and-forget)しているため、秘書セッションが処理中のときに送った2回目以降の通知が消失している可能性が高い。

hookは毎回正常に発火している。問題はhookの先の「秘書セッションへの配信」部分にあると推定される。

⚠️ この結論は調査ログとコード分析に基づく推定であり、再現テストによる検証は未実施。代替仮説は「未排除の代替仮説」セクションを参照。


調査結果

1. notify-secretary-permission.sh の実装確認

結果: hook自体は正常に動作している

2. settings.json のhook設定確認

結果: 設定は正しい

"PermissionRequest": [
  {
    "matcher": "Edit|Write",
    "hooks": [
      {
        "type": "command",
        "command": "~/.claude/scripts/notify-secretary-permission.sh"
      }
    ]
  }
]

3. ログ分析

結果: hookは毎回発火し、tmux.py sendも実行されている

ログの証拠:

[06:42:04] FORCE(claude-dir): SESSION=main:4 TOOL=Edit FILE=~/.claude/scripts/rate-limit-recovery.sh
warning: Ignoring unsupported Python request ...
[06:42:35] FORCE(claude-dir): SESSION=main:4 TOOL=Edit FILE=~/.claude/scripts/rate-limit-recovery.sh
warning: Ignoring unsupported Python request ...

4. tmux.py send の実装分析 — 推定される障害点

cmd_send 関数(tmux.py L207-219):

def cmd_send(args):
    name = args[0]
    prompt = " ".join(args[1:])
    tmux("send-keys", "-t", name, "-l", prompt)  # テキストを入力
    tmux("send-keys", "-t", name, "Enter")        # Enterで送信

問題: 受信側セッションの状態を一切確認していない(fire-and-forget)

推定シナリオ(要検証)

以下は調査ログとコード分析から最も蓋然性が高いと判断したシナリオ。ただし capture-pane の時系列記録による実証は未実施であり、確定ではない。

時刻 06:42:04  1回目のPermission発生
  hook発火 → tmux.py send secretary "msg1"
  秘書は ❯ プロンプト状態 → msg1 送信成功(推定)
  秘書がmsg1の処理を開始(spinnerが回る)

時刻 06:42:35  2回目のPermission発生(秘書はまだ処理中と推定)
  hook発火 → tmux.py send secretary "msg2"
  send-keys -l "msg2" → テキストが入力エリアに入る(推定)
  send-keys Enter → Claude Code TUIが処理中のためEnterの扱いが不明
  → msg2が処理されない可能性

検証に必要な証拠:

  1. 2回目の send 直後の capture-pane -t secretary — 入力欄にテキストが残っているか
  2. secretary pane の -t 解決先確認 — tmux display -t secretary -p '#{session_name}:#{window_index}.#{pane_index}'
  3. Claude Code TUI が処理中にキー入力を受けた場合の挙動 — バッファ/破棄/無視のいずれか

対比: notify-secretary-blocked.sh が問題にならない理由

AskUserQuestion は同じ tmux.py send を使うが問題が顕在化しにくい:

ただし、AskUserQuestion についても「連発しにくい」は頻度の説明であって安全性の証明ではない。同じ fire-and-forget の問題は潜在的に存在する。

5. PermissionRequest hook仕様

context7 に PermissionRequest hook の詳細ドキュメントなし。ログから確認した事実:


未排除の代替仮説

推定シナリオ以外に、以下の可能性が未排除:

# 仮説 排除に必要な検証
A -t secretary が意図しない pane/window を指している tmux display -t secretary -p '#{session_name}:#{window_index}.#{pane_index}' で確認
B secretary が処理中ではなく別の modal 状態だった 2回目 send 直後の capture-pane でUI状態を確認
C send 自体は成功しているが、秘書側ロジックでメッセージを無視している secretary のログ・応答履歴を確認
D send-keys のテキスト入力と Enter の順序が崩れている 高負荷時の tmux send-keys 挙動を検証

これらの仮説は修正案C(キューファイル方式)の導入によってすべて対処される。キューに書き出すことで「送信側の問題」と「配信の信頼性」を分離できるため、原因の特定を待たずに問題を解決できる。


修正案

現設計の問題(3点)

# 問題 影響
1 fire-and-forget 受信側の状態を確認せず送信。処理中セッションへの送信失敗時にメッセージがロスト
2 TOCTOU(Time of Check to Time of Use) 旧案A/Bの waitsend の間に状態が変わりうる。ready 確認後に send する設計では、確認と実行のタイミングのズレでメッセージロストが起きる
3 flock は macOS 未搭載 旧案A/Bの排他制御がそのまま実装不能

案C: キューファイル方式(推奨)

設計原則: Producer(hook)と Consumer(配信)を完全に分離し、メッセージの永続化を保証する

┌──────────┐   write JSON   ┌─────────────────────┐   read+send   ┌───────────┐
│   Hook   │ ─────────────→ │     Spool Dir        │ ←──────────── │   Drain   │
│(producer)│                 │ ~/.claude/spool/     │               │(consumer) │
└──────────┘                 │     secretary/       │               └───────────┘
      │                      └─────────────────────┘                     │
      │  ntfy (即時)                                          tmux send-keys
      ↓                                                              ↓
 ┌────────┐                                                   ┌───────────┐
 │ スマホ  │                                                   │ Secretary │
 └────────┘                                                   └───────────┘

なぜキューファイル方式で TOCTOU が問題にならないか

旧設計(waitsend):

wait が "ready" を返す → [TOCTOU窓] → send を実行
                         この間に secretary が処理開始
                         → send 失敗 → メッセージがロスト(正確性の問題)

キューファイル方式(二重防御):

drain が "ready" を検出 → send を実行 → 配送確認(ACK)
  ACK成功(ready → processing に遷移)→ キューから削除
  ACK失敗(ready のまま)→ メッセージはキューに残留 → 次回リトライ

三重の防御:

  1. キューによる永続化: send の成否に関わらずメッセージはファイルに残る
  2. 配送確認(ACK): tmux send-keys の exit code ではなく、secretary の状態遷移(ready → processing)で配送を検証。遷移が確認できた場合のみキューから削除
  3. リトライ: ACK 失敗時はキューに残留し、次回の drain サイクルで再送

TOCTOU は「確認と実行のズレ」そのものではなく、ズレた結果メッセージがロストすることが問題。 キューファイル方式 + 配送確認により、TOCTOU は配信遅延に格下げされ、正確性の問題ではなくなる。

Producer: hook スクリプト修正案

#!/bin/bash
# notify-secretary-permission.sh (modified)
# Hook の責務: (1) キューに書く (2) ntfy で即通知 (3) drain を起動

LOG="$HOME/.claude/logs/permission-notify.log"
SPOOL_DIR="$HOME/.claude/spool/secretary"
LOCKDIR="$HOME/.claude/locks/secretary-drain.lock"
mkdir -p "$SPOOL_DIR" "$(dirname "$LOCKDIR")"

# ... 既存のフィルタリングロジック(SKIP判定等)は維持 ...

# === 1. キューに書き出し ===
# atomic write: 一時ファイルに書いてから rename(APFS/HFS+ で atomic)
TIMESTAMP=$(date +%s)
TMP_FILE="$SPOOL_DIR/.tmp-$$"
MSG_FILE="$SPOOL_DIR/${TIMESTAMP}-$$.json"

# python3 の json.dump で安全にエンコード(特殊文字・引用符を確実にエスケープ)
python3 -c "
import json, sys
from datetime import datetime, timezone
s, t, p = sys.argv[1], sys.argv[2], sys.argv[3]
json.dump({
    'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
    'session': s, 'tool': t, 'file': p,
    'message': f'Permission待ち: {s} {t} {p}'
}, open(sys.argv[4], 'w'), ensure_ascii=False)
" "$SESSION" "$TOOL_NAME" "$SHORT_PATH" "$TMP_FILE"
mv "$TMP_FILE" "$MSG_FILE"  # rename は APFS/HFS+ で atomic

echo "[$(date '+%H:%M:%S')] QUEUED: $MSG_FILE" >> "$LOG"

# === 2. ntfy で即時通知(ユーザーのスマホ向け)===
curl -s -H "Title: Permission待ち" -H "Priority: high" -H "Tags: lock" \
  -d "${SESSION} ${TOOL_NAME} ${SHORT_PATH}" \
  ntfy.sh/aihara-64d1132d60c2 2>/dev/null &

# === 3. drain を起動(バックグラウンド)===
# stale lock 検出: 5分以上古い lock かつ PID が死んでいれば除去
if [ -d "$LOCKDIR" ]; then
    LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$LOCKDIR") ))
    if [ "$LOCK_AGE" -gt 300 ]; then
        LOCK_PID=$(cat "$LOCKDIR/pid" 2>/dev/null)
        if [ -z "$LOCK_PID" ] || ! kill -0 "$LOCK_PID" 2>/dev/null; then
            rm -f "$LOCKDIR/pid"
            rmdir "$LOCKDIR" 2>/dev/null
            echo "[$(date '+%H:%M:%S')] STALE_LOCK: removed (age=${LOCK_AGE}s, pid=${LOCK_PID:-unknown})" >> "$LOG"
        fi
    fi
fi

("$HOME/.claude/scripts/drain-secretary-queue.sh" >> "$LOG" 2>&1) &

exit 0

前提: tmux.py に status サブコマンドを追加

配送確認には、ブロッキングなしの即時状態チェックが必要。既存の wait は ready になるまでポーリングするため、send 後の確認には使えない。

# tmux.py に追加するサブコマンド(既存の detect_status を再利用)
def cmd_status(args: list[str]) -> None:
    """Non-blocking instant status check."""
    if not args:
        print("Usage: tmux.py status SESSION_NAME", file=sys.stderr)
        sys.exit(1)
    name = args[0]
    try:
        screen = filter_tui_chrome(capture_pane_raw(name))
    except RuntimeError:
        print("session_gone", file=sys.stderr)
        sys.exit(1)
    print(detect_status(screen))

Consumer: drain スクリプト

#!/bin/bash
# drain-secretary-queue.sh
# 責務: キューからメッセージを取り出し、secretary が ready になったら送信し、配送を確認
# 設計:
#   - mkdir ロックで単一インスタンスを保証
#   - ready 待ち・配送確認ともに非ブロッキングの `status` サブコマンドで統一
#   - ACK 条件: status の exit code が 0 かつ "processing" に遷移した場合のみ配送成功
#   - 配送未確認時はキューに残留し、drain 自身が DRAIN_TIMEOUT 内でリトライ
#   - tmux send-keys の成功 ≠ TUI の受信。状態遷移を ACK(受信確認)として扱う

SPOOL_DIR="$HOME/.claude/spool/secretary"
LOCKDIR="$HOME/.claude/locks/secretary-drain.lock"
TMUX_PY="$HOME/.claude/skills/tmux/scripts/tmux.py"
ACK_WAIT=6           # 配送確認ポーリング: 6 × 0.5s = 3秒
DRAIN_TIMEOUT=300    # drain 全体のタイムアウト: 5分
RETRY_INTERVAL=5     # ACK 失敗時のリトライ間隔(秒)

# --- mkdir ロック(macOS 互換の排他制御)---
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    echo "[$(date '+%H:%M:%S')] DRAIN: already running, skip"
    exit 0
fi
# PID を記録(stale lock 検出で kill -0 に使う)
echo $$ > "$LOCKDIR/pid"
trap 'rm -f "$LOCKDIR/pid"; rmdir "$LOCKDIR" 2>/dev/null' EXIT

# 壊れたメッセージの隔離先
QUARANTINE_DIR="$SPOOL_DIR/.quarantine"
mkdir -p "$QUARANTINE_DIR"

# --- メッセージ処理ループ(DRAIN_TIMEOUT 内でリトライ)---
DRAIN_END=$(($(date +%s) + DRAIN_TIMEOUT))

while [ "$(date +%s)" -lt "$DRAIN_END" ]; do
    # 最古のメッセージを1つ取得(glob 展開ベース)
    MSG_FILE=""
    for f in "$SPOOL_DIR"/*.json; do
        [ -e "$f" ] && MSG_FILE="$f" && break
    done
    [ -z "$MSG_FILE" ] && break  # スプール空 → drain 完了

    # JSON から message フィールドを抽出
    MSG=$(python3 -c "
import json, sys
with open(sys.argv[1]) as f:
    print(json.load(f)['message'])
" "$MSG_FILE" 2>/dev/null)

    if [ -z "$MSG" ]; then
        echo "[$(date '+%H:%M:%S')] DRAIN: invalid message, quarantining $MSG_FILE"
        mv "$MSG_FILE" "$QUARANTINE_DIR/" 2>/dev/null
        continue
    fi

    # === secretary の ready 待ち(非ブロッキング status ポーリング)===
    # wait サブコマンドは最長5分ブロックするため使わない。
    # status サブコマンドは即座に結果を返す。
    STATUS=$(uv run --script "$TMUX_PY" status secretary 2>/dev/null)
    STATUS_RC=$?
    if [ "$STATUS_RC" -ne 0 ] || [ "$STATUS" != "ready" ]; then
        sleep 0.5
        continue  # while ループに戻ってリトライ
    fi

    echo "[$(date '+%H:%M:%S')] DRAIN: secretary is ready, sending $MSG_FILE"

    # === 送信 ===
    uv run --script "$TMUX_PY" send secretary "$MSG" 2>/dev/null

    # === 配送確認(ACK)===
    # tmux send-keys の exit code は「tmux にキーを渡せたか」を示すだけ。
    # TUI が実際にメッセージを受信して処理を開始したかは、
    # secretary の状態が ready → processing に遷移したかで判定する。
    # ACK 成功条件: status の exit code = 0 かつ status = "processing"
    # (session_gone や空文字は ACK 失敗として扱う)
    DELIVERED=false
    for _ in $(seq 1 $ACK_WAIT); do
        sleep 0.5
        POST_STATUS=$(uv run --script "$TMUX_PY" status secretary 2>/dev/null)
        POST_RC=$?
        if [ "$POST_RC" -eq 0 ] && [ "$POST_STATUS" = "processing" ]; then
            DELIVERED=true
            echo "[$(date '+%H:%M:%S')] DRAIN: delivered (ACK: processing confirmed)"
            rm -f "$MSG_FILE"
            break
        fi
    done

    if [ "$DELIVERED" = "false" ]; then
        echo "[$(date '+%H:%M:%S')] DRAIN: ACK failed (status=${POST_STATUS:-empty}, rc=$POST_RC), keeping in queue"
        sleep "$RETRY_INTERVAL"
        # while ループに戻ってリトライ(同じメッセージを再試行)
    fi
done

echo "[$(date '+%H:%M:%S')] DRAIN: done"

排他制御: なぜ mkdir か

方式 macOS Linux 備考
flock ❌ 未搭載 brew install flock で追加可能だが外部依存
fcntl.flock (Python) Python プロセス内のみ有効。bash から呼べない
mkdir POSIX 標準で atomic。bash から直接使える
shlock ✅ (BSD由来) 移植性が低い

mkdir は POSIX 標準で「ディレクトリが存在しなければ作成、存在すれば失敗」を atomic に保証する。APFS・HFS+・ext4 すべてで安全に動作する。trap EXIT で正常終了時はクリーンアップされる。

stale lock 対策: SIGKILL でプロセスが死んだ場合 trap は発火しない。対策として drain は lock ディレクトリ内に PID ファイルを書き込む。hook 側は lock の mtime が5分以上古い場合に kill -0 $PID でプロセス生存を確認し、死亡済みの場合のみ lock を除去する(上記 Producer のコード参照)。これにより、正常動作中の drain が5分超待機しても lock が誤って削除されることはない。

案A: ntfy 即時通知のみ(最小実装)

hook から ntfy だけ送り、secretary への配信を諦める。

curl -s -H "Title: Permission待ち" -H "Priority: high" \
  -d "${SESSION} ${TOOL_NAME} ${SHORT_PATH}" \
  ntfy.sh/aihara-64d1132d60c2

メリット: 実装コストほぼゼロ。ユーザーは即座に気づける。 デメリット: Telegram での詳細通知なし。secretary が permission を自動承認できない。

案B: send-if-ready リトライ(中間案)

tmux.py に send-if-ready サブコマンドを追加し、hook プロセス内でリトライ。

メリット: キューファイル不要で実装がシンプル。 デメリット: hook プロセスが最大60秒ブロック。hook 同時発火時にプロセス蓄積。タイムアウト時にメッセージロスト(キューがないため永続化されない)。TOCTOU は内包したまま。


推奨: 案C(キューファイル方式)

評価軸 案A (ntfyのみ) 案B (リトライ) 案C (キュー)
メッセージロスト なし(通知のみ) タイムアウト時ロスト ゼロ
TOCTOU 耐性 N/A ❌ 内包 ✅ ロスト→遅延に格下げ
macOS 互換 ❌ (flock依存) ✅ (mkdir)
hook の実行時間 <100ms 最大60秒 <100ms
秘書の自動承認 ❌ 不可 ✅ 可能 ✅ 可能
実装コスト 極小

実装ステップ

  1. ~/.claude/spool/secretary/ ディレクトリ作成実装済み(2026-04-04)
  2. notify-secretary-permission.sh を修正: fire-and-forget → queue write + ntfy + drain kick実装済み(2026-04-04)
  3. drain-secretary-queue.sh を新規作成実装済み(2026-04-04)
  4. tmux.pystatus サブコマンドを追加(非ブロッキング即時状態チェック)実装済み(2026-04-04)
  5. 動作検証: pytest 24件 + 統合テスト 6件(test-secretary-queue.sh)全テスト GREEN(2026-04-04)
  6. 本番検証: 秘書処理中に permission を連続発生させ、キューへの蓄積 → secretary ready 後の配信を確認 — 🔴 未着手

付録: Codex レビュー履歴

Round 1 (2026-04-02)

総評:

「hook は動いている、詰まるのはその先だ」という切り分けまでは妥当。だが、肝心の根本原因と障害フローは証拠不足の推定を断定にしている。修正案A/Bもその推定の上に立っているぶん設計として甘い。

🔴 Critical(修正必須):

  1. 原因の断定根拠が不足 — ログが示すのは「hook発火」「uv run起動」まで。tmux.py send の終了コード、送信直後の secretary pane 内容、2回目送信後に入力欄へ文字が残った証拠がない。代替原因(pane 誤指定、modal 状態、秘書側ロジック見落とし)も潰せていない。

  2. 障害シナリオが技術的に未検証 — 「テキストは入力欄に残る」「Enterは消費済みで再送信されない」は推測。Claude Code TUI が処理中に文字入力をどう扱うかは未検証。

  3. 案A/Bの wait → send は TOCTOUwait は prompt 検出 + fingerprint 安定を見ているだけで、入力欄の状態やフォーカス位置を保証しない。wait 直後の状態遷移で send が失敗する可能性がある。

🟡 Warning(推奨):

  1. flock は macOS に存在しない — 案A/Bはそのまま実装不能。mkdir ロックや shlock に変更すべき。

  2. 案Bは修正ではなく迂回策 — ntfy で「気づく」ことに寄りすぎて、秘書通知の信頼性改善という技術課題の答えになっていない。

  3. lock + background wait のプロセス蓄積 — 秘書が長時間 busy のとき hook ごとに待機プロセスが蓄積し、120秒タイムアウトで黙って通知が落ちる。

🟢 Info(参考):

  1. 案Cが実は一番筋がいい — hook は append するだけ、secretary 側が drain するだけに分離すれば、wait や TUI 状態判定の不確実性を transport 層から追い出せる。

Codex が評価した良い点:

Round 1 の指摘への対応状況:

# 指摘 対応
1 原因の断定根拠が不足 ✅ 結論を「最有力仮説」に変更。推定シナリオに「要検証」ラベル追加。未排除の代替仮説セクション新設
2 障害シナリオが未検証 ✅ 「推定シナリオ(要検証)」に改名。検証に必要な証拠を3点明記
3 wait→send は TOCTOU ✅ 案C(キューファイル方式)に変更。TOCTOU をメッセージロスト(正確性)→配信遅延(パフォーマンス)に格下げ
4 flock は macOS に存在しない ✅ mkdir ロックに変更。macOS/Linux 互換の排他制御比較表を追加
5 案Bは迂回策 ✅ 案Cを推奨に格上げ。案Bは中間案として残すが推奨しない
6 プロセス蓄積 ✅ 案Cの hook は <100ms で終了(queue write + drain kick のみ)。drain は mkdir ロックで単一インスタンス保証
7 案Cが一番筋がいい ✅ 案Cを推奨案として詳細設計を記載

Round 2 (2026-04-02)

🔴 Critical: send 失敗時にも rm -f → メッセージロスト再発。if uv run ...; then rm; fi に修正すべき。 🟡 Warning: stale lock が mtime だけ(PID確認なし)/ ls|sort の単語分割リスク / 壊れたJSON即削除で障害解析不能。

→ Round 3 で対応: send を if 文で囲む / PID + kill -0 追加 / glob 展開ベースに変更 / quarantine ディレクトリに退避

Round 3 (2026-04-02)

🔴 Critical: tmux send-keys の exit code は tmux 成功であって TUI 受信の保証ではない。元の仮説が「send-keys は成功しても busy だと消える」なのに、exit code を配送成功とみなしている。send 後に pane 状態の再確認が必要。 🟡 Warning: JSON ヒアドキュメントの変数エスケープ漏れ。python3/jq で安全にエンコードすべき。

→ Round 4 で対応: ACK を status の遷移(ready → processing)で判定 / JSON 生成を python3 json.dump に変更

Round 4 (2026-04-02)

🔴 Critical: ACK 条件 POST_STATUS != "ready" が甘い。tmux.py statussession_gone で異常終了 → 2>/dev/null で空文字 → 配送成功と誤判定。exit code チェック + processing 限定にすべき。 🟡 Warning: ready 待ちに wait(5分ブロック)使用は設計不整合 / ACK 失敗で break → 次の hook まで再試行なし。

→ Round 5 で対応: exit code 0 かつ processing のみ ACK 成功 / status ベースの非ブロッキングポーリングに統一 / drain 自身が DRAIN_TIMEOUT 内でリトライ

Round 5 (2026-04-02) — LGTM ✅

🔴 Critical: なし 🟡 Warning: なし

Codex 総評:

ACK を「tmux send-keys が成功したか」ではなく、「secretary が ready → processing に遷移したか」で扱い直したのは筋がいい。キュー永続化、非ブロッキング状態確認、drain 側リトライの3点が噛み合っていて、SRE 的にも設計がかなり締まった。

Round 6 (2026-04-05) — has-working + セキュリティ強化レビュー

対象ファイル: notify-secretary-permission.sh, drain-secretary-queue.sh, test-secretary-queue.sh, tmux.py (has-working / has_working_indicators)

初回指摘 (High 1件, Medium 3件, Low 2件):

対応:

  1. TMP_FILE を mktemp "$SPOOL_DIR/.tmp.XXXXXX" で O_EXCL 排他生成に変更
  2. auto-approve 判定を 3 回リトライ(0.5s x 3)に変更、全回不在で初めて削除
  3. .inflight/ ディレクトリ追加。status == ready 確認後に claim、send/ACK 失敗時は spool に返却
  4. _SPINNER_PROGRESSr"^[…]\s+\S.*…" に緩和(複数語・日本語・絵文字対応)

再レビュー結果: LGTM ✅

Codex 総評:

前回の High / Medium 指摘は十分に潰れている。_SPINNER_PROGRESS 緩和は実ファイルでも確認。inflight claim の配置(status == ready 後)は status 待ちでファイル孤立を防ぐ正しい判断。

テスト結果: test-secretary-queue.sh 10 passed / tmux.py pytest 24 passed

📝 質問モード — テキストを選択してね
✓ 質問を送信しました