最有力仮説: tmux.py send が受信側セッションの状態を確認せずに送信(fire-and-forget)しているため、秘書セッションが処理中のときに送った2回目以降の通知が消失している可能性が高い。
hookは毎回正常に発火している。問題はhookの先の「秘書セッションへの配信」部分にあると推定される。
⚠️ この結論は調査ログとコード分析に基づく推定であり、再現テストによる検証は未実施。代替仮説は「未排除の代替仮説」セクションを参照。
結果: hook自体は正常に動作している
PermissionRequest hookとして Edit|Write matcherで登録済み(settings.json L140-149).claude/ 配下のファイルは FORCE(claude-dir) パスで即通知(sleep 0.5のauto-approve判定をスキップ)結果: 設定は正しい
"PermissionRequest": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/notify-secretary-permission.sh"
}
]
}
]
PermissionRequest イベントに Edit|Write matcherでcommand hookとして登録結果: 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 ...
FORCE(claude-dir) まで到達warning は uv run が tmux.py を実行した証拠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が処理されない可能性
検証に必要な証拠:
capture-pane -t secretary — 入力欄にテキストが残っているか-t 解決先確認 — tmux display -t secretary -p '#{session_name}:#{window_index}.#{pane_index}'AskUserQuestion は同じ tmux.py send を使うが問題が顕在化しにくい:
.claude/ 配下の連続編集時に秒単位で連発するただし、AskUserQuestion についても「連発しにくい」は頻度の説明であって安全性の証明ではない。同じ fire-and-forget の問題は潜在的に存在する。
context7 に PermissionRequest hook の詳細ドキュメントなし。ログから確認した事実:
.claude/ 配下は permissionダイアログが出る → 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(キューファイル方式)の導入によってすべて対処される。キューに書き出すことで「送信側の問題」と「配信の信頼性」を分離できるため、原因の特定を待たずに問題を解決できる。
| # | 問題 | 影響 |
|---|---|---|
| 1 | fire-and-forget | 受信側の状態を確認せず送信。処理中セッションへの送信失敗時にメッセージがロスト |
| 2 | TOCTOU(Time of Check to Time of Use) | 旧案A/Bの wait → send の間に状態が変わりうる。ready 確認後に send する設計では、確認と実行のタイミングのズレでメッセージロストが起きる |
| 3 | flock は macOS 未搭載 |
旧案A/Bの排他制御がそのまま実装不能 |
設計原則: Producer(hook)と Consumer(配信)を完全に分離し、メッセージの永続化を保証する
┌──────────┐ write JSON ┌─────────────────────┐ read+send ┌───────────┐
│ Hook │ ─────────────→ │ Spool Dir │ ←──────────── │ Drain │
│(producer)│ │ ~/.claude/spool/ │ │(consumer) │
└──────────┘ │ secretary/ │ └───────────┘
│ └─────────────────────┘ │
│ ntfy (即時) tmux send-keys
↓ ↓
┌────────┐ ┌───────────┐
│ スマホ │ │ Secretary │
└────────┘ └───────────┘
旧設計(wait → send):
wait が "ready" を返す → [TOCTOU窓] → send を実行
この間に secretary が処理開始
→ send 失敗 → メッセージがロスト(正確性の問題)
キューファイル方式(二重防御):
drain が "ready" を検出 → send を実行 → 配送確認(ACK)
ACK成功(ready → processing に遷移)→ キューから削除
ACK失敗(ready のまま)→ メッセージはキューに残留 → 次回リトライ
三重の防御:
tmux send-keys の exit code ではなく、secretary の状態遷移(ready → processing)で配送を検証。遷移が確認できた場合のみキューから削除TOCTOU は「確認と実行のズレ」そのものではなく、ズレた結果メッセージがロストすることが問題。 キューファイル方式 + 配送確認により、TOCTOU は配信遅延に格下げされ、正確性の問題ではなくなる。
#!/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
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))
#!/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"
| 方式 | 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 が誤って削除されることはない。
hook から ntfy だけ送り、secretary への配信を諦める。
curl -s -H "Title: Permission待ち" -H "Priority: high" \
-d "${SESSION} ${TOOL_NAME} ${SHORT_PATH}" \
ntfy.sh/aihara-64d1132d60c2
メリット: 実装コストほぼゼロ。ユーザーは即座に気づける。 デメリット: Telegram での詳細通知なし。secretary が permission を自動承認できない。
tmux.py に send-if-ready サブコマンドを追加し、hook プロセス内でリトライ。
メリット: キューファイル不要で実装がシンプル。 デメリット: hook プロセスが最大60秒ブロック。hook 同時発火時にプロセス蓄積。タイムアウト時にメッセージロスト(キューがないため永続化されない)。TOCTOU は内包したまま。
| 評価軸 | 案A (ntfyのみ) | 案B (リトライ) | 案C (キュー) |
|---|---|---|---|
| メッセージロスト | なし(通知のみ) | タイムアウト時ロスト | ゼロ |
| TOCTOU 耐性 | N/A | ❌ 内包 | ✅ ロスト→遅延に格下げ |
| macOS 互換 | ✅ | ❌ (flock依存) | ✅ (mkdir) |
| hook の実行時間 | <100ms | 最大60秒 | <100ms |
| 秘書の自動承認 | ❌ 不可 | ✅ 可能 | ✅ 可能 |
| 実装コスト | 極小 | 小 | 中 |
~/.claude/spool/secretary/ ディレクトリ作成notify-secretary-permission.sh を修正: fire-and-forget → queue write + ntfy + drain kickdrain-secretary-queue.sh を新規作成tmux.py に status サブコマンドを追加(非ブロッキング即時状態チェック)総評:
「hook は動いている、詰まるのはその先だ」という切り分けまでは妥当。だが、肝心の根本原因と障害フローは証拠不足の推定を断定にしている。修正案A/Bもその推定の上に立っているぶん設計として甘い。
🔴 Critical(修正必須):
原因の断定根拠が不足 — ログが示すのは「hook発火」「uv run起動」まで。tmux.py send の終了コード、送信直後の secretary pane 内容、2回目送信後に入力欄へ文字が残った証拠がない。代替原因(pane 誤指定、modal 状態、秘書側ロジック見落とし)も潰せていない。
障害シナリオが技術的に未検証 — 「テキストは入力欄に残る」「Enterは消費済みで再送信されない」は推測。Claude Code TUI が処理中に文字入力をどう扱うかは未検証。
案A/Bの wait → send は TOCTOU — wait は prompt 検出 + fingerprint 安定を見ているだけで、入力欄の状態やフォーカス位置を保証しない。wait 直後の状態遷移で send が失敗する可能性がある。
🟡 Warning(推奨):
flock は macOS に存在しない — 案A/Bはそのまま実装不能。mkdir ロックや shlock に変更すべき。
案Bは修正ではなく迂回策 — ntfy で「気づく」ことに寄りすぎて、秘書通知の信頼性改善という技術課題の答えになっていない。
lock + background wait のプロセス蓄積 — 秘書が長時間 busy のとき hook ごとに待機プロセスが蓄積し、120秒タイムアウトで黙って通知が落ちる。
🟢 Info(参考):
Codex が評価した良い点:
tmux.py send の実装まで追って「少なくとも hook 未発火や matcher ミスではない」と切り分けた点は適切。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を推奨案として詳細設計を記載 |
🔴 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 ディレクトリに退避
🔴 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 に変更
🔴 Critical: ACK 条件 POST_STATUS != "ready" が甘い。tmux.py status が session_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 内でリトライ
🔴 Critical: なし 🟡 Warning: なし
Codex 総評:
ACK を「tmux send-keys が成功したか」ではなく、「secretary が ready → processing に遷移したか」で扱い直したのは筋がいい。キュー永続化、非ブロッキング状態確認、drain 側リトライの3点が噛み合っていて、SRE 的にも設計がかなり締まった。
対象ファイル: 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件):
$TIMESTAMP-$$.json)→ symlink 攻撃の余地_SPINNER_PROGRESS 正規表現が \w+… で狭すぎ → ACK false negative対応:
mktemp "$SPOOL_DIR/.tmp.XXXXXX" で O_EXCL 排他生成に変更.inflight/ ディレクトリ追加。status == ready 確認後に claim、send/ACK 失敗時は spool に返却_SPINNER_PROGRESS を r"^[…]\s+\S.*…" に緩和(複数語・日本語・絵文字対応)再レビュー結果: LGTM ✅
Codex 総評:
前回の High / Medium 指摘は十分に潰れている。
_SPINNER_PROGRESS緩和は実ファイルでも確認。inflight claim の配置(status == ready 後)は status 待ちでファイル孤立を防ぐ正しい判断。
テスト結果: test-secretary-queue.sh 10 passed / tmux.py pytest 24 passed