Claude Code に PostToolUse で eslint --fix を仕込んだ朝、ターミナルが赤いログで埋まりました。Hook 'lint-all' command timed out after 60s — フックは確かに発火しているのに、規模の大きい monorepo を相手にすると 60 秒で打ち切られてしまうのです。私自身、2014年からアプリ開発を続けるなかで、いつの間にか肥大化したフロントエンド側のリント設定がそのまま Claude Code の自動修正フローに足を引っ張る、というのは結構ありがちな場面でした。
このエラーは Hook の non-zero exit code とは別物で、コマンド自体は正しく動き始めているのに、Claude Code 側のウォッチドッグが上限秒数で SIGTERM を投げて停止させているために起こります。原因の切り分けと、ハマらない設定の組み立て方を順に書き出します。
command timed out が出る瞬間に何が起きているのか
Claude Code は Hook 実行時に、設定した(または既定の)秒数を超えるとプロセスツリーを SIGTERM で停止します。既定値はバージョンによって異なりますが、私の手元のクライアントでは 60 秒でした。フック側のコマンドは独立した子プロセスで起動するため、Claude Code 本体のセッションは生きていても、フックだけが「途中で死ぬ」状態になります。
ここで一番見落としやすいのは、Hook の中で & でバックグラウンド化していても、プロセスツリー全体が監視対象になっているという点です。npm run lint & のように書いても、シェルが終了するまでに lint が完了していなければ、結局子プロセスごと止められます。フック側で disown するか、後述のように完全に切り離す必要があります。
タイムアウトに引っかかるパターンの典型は、ESLint の全ファイル走査、Prettier の monorepo 全体実行、tsc --noEmit、Jest の全テスト、Docker イメージのビルドあたりです。どれも単体ではよく使うコマンドですが、Hook の文脈で毎回走らせる種類のものではありません。
まず疑うのは「フックの粒度」のほうで、timeout 設定ではない
時間が足りないとき、timeout を 60 → 300 秒に伸ばす対応をしがちですが、これは根本解決にはなりません。Hook はツール呼び出しのたびに走るので、たとえば 5 分の lint を入れると、ファイル 1 つ編集するたびに 5 分待たされる体験になります。Claude Code が補完してくれる速度を生かすには、Hook 自体が秒単位で終わる粒度に絞り込むのが先です。
実用的には、PostToolUse の matcher で対象を Edit / Write / NotebookEdit に絞ったうえで、Hook の中身も「直近に変更されたファイルだけを対象にする」スクリプトに置き換えます。tool_input.file_path を環境変数経由で受け取れる仕組みを利用して、ファイル単位の lint や prettier を回します。
#!/usr/bin/env bash
# .claude/hooks/post-edit-lint.sh
set -euo pipefail
# Claude Code が渡してくる JSON から file_path を取り出す
FILE="$(jq -r '.tool_input.file_path // empty' <<< "${CLAUDE_HOOK_INPUT:-}")"
[[ -z "$FILE" ]] && exit 0
[[ ! -f "$FILE" ]] && exit 0
# 拡張子で振り分けて、対象ファイルだけ処理する
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
npx --no-install eslint --fix --max-warnings=0 "$FILE"
;;
*.md|*.mdx|*.json|*.yaml|*.yml)
npx --no-install prettier --write "$FILE"
;;
esacこのスクリプトなら、eslint . のようにプロジェクト全体を見にいく動きにはならず、編集された 1 ファイルだけが対象になります。私のアプリ開発のリポジトリでも、monorepo 全体だと 90 秒近くかかっていた lint が、ファイル単位なら 2〜3 秒で安定して終わります。
それでも長く走らせたいときの timeout 設定の書き方
「フックを軽くしても、どうしても 60 秒を超える処理を入れたい」場面はあります。たとえば PostToolUse でビルドスクリプトを動かしたい、Stop Hook で大きめのテストを走らせたい、といったケースです。Claude Code の settings.json では、各 Hook ごとに timeout を明示できます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-lint.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/run-build.sh",
"timeout": 300
}
]
}
]
}
}timeout の単位は秒です。ミリ秒で書きたくなる方を時々お見かけしますが、Claude Code の Hook 設定は秒単位で統一されています。500 を入れると 500 秒待つので注意してください。Stop Hook など、ユーザーが「終了」を意図したタイミングで走るフックは長めに取っても体感を損ねません。一方 PostToolUse のように頻繁に発火するフックは、長くても 30 秒程度に抑えるのが現実的です。
設定変更を反映させるには、Claude Code セッションを再起動する必要があります。Hot reload はされない仕様なので、/exit してから入り直すか、別の新しいセッションを開いてください。
バックグラウンドで完全に切り離してから「結果だけ拾う」パターン
Hook で長時間処理を回したいけれど、Claude Code の応答を待たせたくない場合は、フック側でジョブを完全にデタッチしてから別の場所で結果を確認するアプローチが効きます。nohup と setsid の組み合わせで親プロセスから切り離します。
#!/usr/bin/env bash
# .claude/hooks/post-edit-build.sh
set -euo pipefail
LOG_DIR="${HOME}/.claude/hook-logs"
mkdir -p "$LOG_DIR"
TS="$(date +%Y%m%d-%H%M%S)"
LOG="${LOG_DIR}/build-${TS}.log"
# Claude Code 側のフック監視対象から完全に切り離す
setsid nohup bash -c "
cd \"$(pwd)\"
npm run build > '$LOG' 2>&1
echo \"exit=\$?\" >> '$LOG'
" </dev/null >/dev/null 2>&1 &
disown || true
# Hook 自身は即座に成功で返す
echo "build kicked off → $LOG"
exit 0ポイントは setsid で新しいセッションを作り、nohup でハングアップ無視、そして disown でジョブテーブルから外していることです。これで Claude Code のフック監視は数十ミリ秒で完了し、ビルドは裏で続きます。結果はログファイルに残るので、AdMob 収益レポートの集計やデプロイ後の検証など、後追いで確認したい処理向きです。
ただしこの方式は「フックがビルド失敗を即時に知らせてくれない」というトレードオフを伴います。失敗を Claude Code に伝えたいなら、後段で Stop Hook かユーザー側のスクリプトでログをチェックして、エラーがあれば次のターンで Claude Code に共有する、という二段構えにします。
ハマったときに最初に見るログの場所
command timed out が出たかどうかは、Claude Code の標準出力にも記録されますが、流れて消えやすい情報です。Hook 自体の動きを別ログに残すようにしておくと、原因切り分けが速くなります。先ほどの post-edit-lint.sh の冒頭に次の数行を入れておくと、何が呼ばれたかを後から追えます。
# 冒頭のログ出力
LOG_DIR="${HOME}/.claude/hook-logs"
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_DIR/hook-$(date +%Y%m%d).log") 2>&1
echo "[$(date '+%H:%M:%S')] hook=post-edit-lint file=$FILE"これだけで、Hook が呼ばれた瞬間のタイムスタンプと、Claude Code が渡してきた file_path が日次ログに溜まります。タイムアウトの直前で時間がかかっているコマンドが何なのか、set -x を一時的に有効にして洗い出すと原因が見えてきます。
私の手元では、SwiftLint を含む iOS 系のフックが一度 60 秒を越えていて、調べてみると xcodebuild -showBuildSettings を毎回呼んでいたのが原因でした。同じプロジェクトで同じ設定値を毎回問い合わせる必要はないので、初回だけ計算して /tmp にキャッシュする 5 行を追加するだけで 70 秒→3 秒に縮みました。タイムアウトの正体は「重い処理」ではなく、「重い処理を毎回回している設計」のほうにあることが多いです。
私が今使っている Hook 設定の指針
最後に、現時点で私が落ち着いた指針を 5 つだけ書き残します。
第一に、PostToolUse は 30 秒以内に終わるものだけ置く。それ以上かかるものは Stop Hook かバックグラウンドジョブに回します。第二に、フック内で必ず対象ファイルを絞る。tool_input.file_path を見て、対象拡張子でなければ即座に exit 0。第三に、npx --no-install を使って依存ダウンロードを抑える。Hook が初回だけ重くなる現象を避けられます。第四に、ログは必ず日次ファイルに分けて残す。原因切り分けの時間が一気に短くなります。第五に、ビルドや E2E テストのように本質的に長いものは Hook ではなくスケジュールタスクや CI に寄せる。Claude Code のセッションをブロックさせない設計を優先します。
Hook は便利な分、設計を間違えるとセッション全体の体感速度を一気に落とします。「気持ちよく速い Claude Code」を保つために、command timed out が出たら timeout を伸ばす前に粒度を見直す、というのが当面の私のルールです。同じところで時間を溶かしてしまった方の手がかりになれば幸いです。