nekolas note
9.5 KB Raw
#!/usr/bin/env bash
# cc-tmux: 全フック共通エントリ(T007/T012/T013/T014/T017/T020/T022)。
# 参照: contracts/hooks.md
#
# Claude Code の各フックから呼ばれる。stdin の JSON を jq で解析し、
# hook_event_name で分岐する。tmux 外($TMUX 無し)は即 no-op。常に exit 0。
#
# 登録イベント: SessionStart / UserPromptSubmit / PreToolUse / PreCompact / Stop / SessionEnd
set -u

# このスクリプトの位置から cc-tmux ルートを解決。
CC_DIR=$(cd "$(dirname "$0")/.." && pwd)

# ライブラリ読み込み。
# shellcheck source=../lib/state.sh
. "$CC_DIR/lib/state.sh"
# shellcheck source=../lib/tmux.sh
. "$CC_DIR/lib/tmux.sh"
# name.sh は US3 用(存在すれば名前連動を有効化)。
if [ -f "$CC_DIR/lib/name.sh" ]; then
  # shellcheck source=../lib/name.sh
  . "$CC_DIR/lib/name.sh"
fi

# 失敗してもターンを妨げない: 何が起きても最後は exit 0。
log_err() { printf 'cc-tmux-hook: %s\n' "$*" >&2; }

main() {
  # tmux 外なら US1/US3 は無効(FR-010/042)。stdin は読み捨てる。
  if ! cc_in_tmux; then
    cat >/dev/null 2>&1 || true
    return 0
  fi

  # jq が無ければ何もできない(依存不足)。安全に no-op。
  command -v jq >/dev/null 2>&1 || { cat >/dev/null 2>&1 || true; return 0; }

  # stdin JSON を一括取得。
  _input=$(cat 2>/dev/null) || _input=""
  [ -n "$_input" ] || return 0

  # 主要フィールド抽出(欠落は空文字に)。
  _event=$(printf '%s' "$_input" | jq -r '.hook_event_name // empty' 2>/dev/null)
  _sid=$(printf '%s' "$_input" | jq -r '.session_id // empty' 2>/dev/null)
  _cwd=$(printf '%s' "$_input" | jq -r '.cwd // empty' 2>/dev/null)
  _transcript=$(printf '%s' "$_input" | jq -r '.transcript_path // empty' 2>/dev/null)
  # SessionStart の種別(startup|resume|clear|compact)。
  _source=$(printf '%s' "$_input" | jq -r '.source // .matcher // empty' 2>/dev/null)
  # PreCompact の契機(manual=/compact コマンド | auto=自動コンパクション)。
  _trigger=$(printf '%s' "$_input" | jq -r '.trigger // empty' 2>/dev/null)

  [ -n "$_event" ] || return 0
  [ -n "$_sid" ] || return 0

  # window_id を解決(できなければ表示系は無効)。
  _win=$(cc_tmux_window_id "${TMUX_PANE:-}") || _win=""
  [ -n "$_win" ] || return 0

  case "$_event" in
    SessionStart)
      handle_session_start ;;
    UserPromptSubmit|PreToolUse)
      handle_busy ;;
    PreCompact)
      handle_precompact ;;
    Stop)
      handle_idle ;;
    SessionEnd)
      handle_session_end ;;
    *)
      : ;;  # 未知イベントは無視
  esac
  return 0
}

# busy へ遷移し、スピナーを起動(FR-001/002/003/011)。冪等。
handle_busy() {
  ensure_display
  state_init "$_sid"
  state_meta_set "$_sid" tmux_pane "${TMUX_PANE:-}"
  state_meta_set "$_sid" window_id "$_win"
  [ -n "$_cwd" ] && state_meta_set "$_sid" cwd "$_cwd"
  state_set "$_sid" busy
  cc_tmux_set_state "$_win" busy
  start_spinner
  start_namewatch # 落ちていたら再起動(冪等, T044)
  cc_tmux_refresh
}

# PreCompact: busy 表示にしつつ契機(manual|auto)を記録する。
# 手動 /compact はターン外で走り完了後に Stop が来ないため、
# compact 明けの SessionStart(source=compact) で idle へ戻す判断材料にする。
handle_precompact() {
  handle_busy
  [ -n "$_trigger" ] && state_meta_set "$_sid" compact_trigger "$_trigger"
}

# idle へ遷移し、スピナーを停止・@cc_spin クリア(FR-004/008/009)。
handle_idle() {
  state_set "$_sid" idle
  cc_tmux_set_state "$_win" idle
  stop_spinner
  cc_tmux_clear_spin "$_win"
  # US3: /rename ベストエフォート追従(アイドル中に現在名を再確認, FR-041)。
  # 即時反映はウォッチャ(cc-tmux-namewatch)が担い、ここは取りこぼし補完。
  follow_name
  cc_tmux_refresh
}

# US3: /rename・セッション名追従。transcript の customTitle(ユーザーの明示名)を
# 読めた場合、変化時に反映する。明示名は session 名が正(FR-043)なので
# ロック(automatic-rename off)を貫通して適用する。自動導出名(derive 側)は
# 従来どおりロックを尊重する。読めない/形式不一致は静かに諦める(FR-041)。
follow_name() {
  command -v name_follow >/dev/null 2>&1 || return 0
  _cur=$(name_follow "$_sid" "$_transcript" 2>/dev/null) || return 0
  [ -n "$_cur" ] || return 0
  _prev=$(state_meta "$_sid" name 2>/dev/null) || _prev=""
  if [ "$_cur" != "$_prev" ]; then
    state_meta_set "$_sid" name "$_cur"
    cc_tmux_rename_force "$_win" "$_cur"
  fi
}

# tmux 表示フォーマットを遅延適用(プラグイン単体で完結させる)。
# tmux サーバごとに一度だけ(@cc_tmux_fmt センチネルでガード)。
# tmux.conf を編集していなくてもプラグイン導入だけで色+スピナー表示が有効になる。
ensure_display() {
  cc_in_tmux || return 0
  [ "$(tmux show-options -gv @cc_tmux_fmt 2>/dev/null)" = "1" ] && return 0
  if [ -f "$CC_DIR/tmux/cc-tmux.tmux" ]; then
    tmux source-file "$CC_DIR/tmux/cc-tmux.tmux" 2>/dev/null
  fi
  tmux set-option -g @cc_tmux_fmt 1 2>/dev/null
}

# SessionStart: 初期化と名前導出。compact は busy 維持(FR-011 取りこぼし防止)。
handle_session_start() {
  ensure_display
  state_init "$_sid"
  state_meta_set "$_sid" tmux_pane "${TMUX_PANE:-}"
  state_meta_set "$_sid" window_id "$_win"
  [ -n "$_cwd" ] && state_meta_set "$_sid" cwd "$_cwd"
  [ -n "$_transcript" ] && state_meta_set "$_sid" transcript "$_transcript"

  # 同一 pane の孤児(クラッシュ後)を掃除。
  state_cleanup_stale_pane "${TMUX_PANE:-}" "$_sid"

  if [ "$_source" = "compact" ]; then
    # コンパクション明け: 契機で分岐する。
    #   manual(/compact コマンド): この後 Stop が来ないため idle へ戻す。
    #   auto(ターン中の自動圧縮): busy を維持(ターン末の Stop で戻る, FR-011)。
    _ct=$(state_meta "$_sid" compact_trigger 2>/dev/null) || _ct=""
    state_meta_set "$_sid" compact_trigger ""
    if [ "$_ct" = "manual" ]; then
      state_set "$_sid" idle
      cc_tmux_set_state "$_win" idle
      stop_spinner
      cc_tmux_clear_spin "$_win"
    elif [ "$(state_get "$_sid" 2>/dev/null)" = "busy" ]; then
      cc_tmux_set_state "$_win" busy
      start_spinner
    fi
    cc_tmux_refresh
    return 0
  fi

  # 通常起動(startup/resume/clear): idle で初期化。
  state_set "$_sid" idle
  cc_tmux_set_state "$_win" idle
  cc_tmux_clear_spin "$_win"

  # US3: 名前導出(name.sh があれば)。
  derive_and_apply_name

  # US3: /rename 即時追従ウォッチャを起動(T044, FR-041 強化)。
  start_namewatch

  cc_tmux_refresh
}

# SessionEnd: 表示クリア・状態破棄(FR-060 復元の一部)。
handle_session_end() {
  stop_spinner
  stop_namewatch
  cc_tmux_clear_all "$_win"
  state_cleanup "$_sid"
  cc_tmux_refresh
}

# スピナー起動(多重防止は spinner 側 pidfile でも担保)。
start_spinner() {
  _spin_bin="$CC_DIR/bin/cc-tmux-spinner"
  [ -x "$_spin_bin" ] || return 0
  _pidf="$(cc_state_dir "$_sid")/spinner.pid"
  if [ -f "$_pidf" ]; then
    _p=$(cat "$_pidf" 2>/dev/null)
    if [ -n "$_p" ] && kill -0 "$_p" 2>/dev/null; then
      return 0  # 既に稼働中
    fi
  fi
  # フックより長生きさせるため完全にデタッチ(フック終了の SIGHUP で死なせない)。
  if command -v setsid >/dev/null 2>&1; then
    setsid "$_spin_bin" "$_sid" "$_win" >/dev/null 2>&1 </dev/null &
  else
    nohup "$_spin_bin" "$_sid" "$_win" >/dev/null 2>&1 </dev/null &
  fi
  # bash の場合はジョブ管理から外す(SIGHUP 連鎖防止)。
  disown 2>/dev/null || true
  return 0
}

# スピナー停止。
stop_spinner() {
  _pidf="$(cc_state_dir "$_sid")/spinner.pid"
  if [ -f "$_pidf" ]; then
    _p=$(cat "$_pidf" 2>/dev/null)
    [ -n "$_p" ] && kill "$_p" 2>/dev/null
    rm -f "$_pidf" 2>/dev/null
  fi
}

# /rename 即時追従ウォッチャ起動(T044)。スピナーと同じデタッチ+pidfile 方式。冪等。
start_namewatch() {
  [ -n "$_transcript" ] || return 0
  _nw_bin="$CC_DIR/bin/cc-tmux-namewatch"
  [ -x "$_nw_bin" ] || return 0
  _nw_pidf="$(cc_state_dir "$_sid")/namewatch.pid"
  if [ -f "$_nw_pidf" ]; then
    _p=$(cat "$_nw_pidf" 2>/dev/null)
    if [ -n "$_p" ] && kill -0 "$_p" 2>/dev/null; then
      return 0  # 既に稼働中
    fi
  fi
  if command -v setsid >/dev/null 2>&1; then
    setsid "$_nw_bin" "$_sid" "$_win" "$_transcript" >/dev/null 2>&1 </dev/null &
  else
    nohup "$_nw_bin" "$_sid" "$_win" "$_transcript" >/dev/null 2>&1 </dev/null &
  fi
  disown 2>/dev/null || true
  return 0
}

# ウォッチャ停止。
stop_namewatch() {
  _nw_pidf="$(cc_state_dir "$_sid")/namewatch.pid"
  if [ -f "$_nw_pidf" ]; then
    _p=$(cat "$_nw_pidf" 2>/dev/null)
    [ -n "$_p" ] && kill "$_p" 2>/dev/null
    rm -f "$_nw_pidf" 2>/dev/null
  fi
}

# US3: 名前導出して未ロック時のみ適用(name.sh が無ければ no-op)。
derive_and_apply_name() {
  command -v name_derive >/dev/null 2>&1 || return 0
  # 既にロック(ユーザー設定名)なら何もしない(FR-044)。
  if cc_tmux_name_locked "$_win"; then
    state_meta_set "$_sid" name_locked 1
    return 0
  fi
  state_meta_set "$_sid" name_locked 0
  _name=$(name_derive "$_sid" "$_cwd" "$_transcript" 2>/dev/null) || _name=""
  if [ -n "$_name" ]; then
    state_meta_set "$_sid" name "$_name"
    cc_tmux_rename "$_win" "$_name"
  fi
}

main
exit 0