1本のログファイルに全部混ざって、原因追跡に1時間かかった
当社(合同会社ジョインクラス)は launchd で17個の自動化ジョブを回していて、そのうち5つが claude -p を無人実行します。記事補充、部門レポート、STATE更新、営業パイプライン、売上集計。
しばらく >> logs/auto-replenish.log という素朴な追記でやっていたのですが、問題が起きました。
- 同じスクリプトが22:00の定期実行と手動リトライで同時に走り、2実行分の行が1ファイルに交互に混ざる
- 失敗したとき「どこまで進んだか」を読み解くのに、タイムスタンプを見比べる作業が発生する
- Claude Code 側のセッション(会話)と、シェル側のログが紐づかない。
--resumeしたいのに ID がどこにも残っていない
解決策は単純で、実行ごとに一意な ID を持たせて、ログをその ID で分けるだけです。そして Claude Code には最初からその ID が用意されています。session_id です。
この記事では、僕が実際に動かしている実装をベースに、
- Hook の stdin JSON から
session_idを取り出してログを分離する - 無人実行(
claude -p)側で--output-format jsonのsession_idを捕まえる
の2系統を書きます。どちらもコピペしてその日に動きます。
前提: Hook は stdin から JSON を受け取る
Claude Code の Hook は、環境変数ではなく stdin に JSON が流れてきます。ここが最初の落とし穴で、$SESSION_ID みたいな変数を探しても存在しません。
PreToolUse の入力はだいたいこういう形です。
{
"session_id": "3f9c1a2e-7b44-4e21-9f0a-5c6d8e2b1a77",
"transcript_path": "/Users/kyoagun/.claude/projects/-Users-kyoagun-workspace-one-ceo/3f9c1a2e-....jsonl",
"cwd": "/Users/kyoagun/workspace/one-ceo",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "git push origin main",
"description": "Push to remote"
}
}
実際に何が来るか確認したいときは、まず素通しのHookを1本仕掛けて生JSONを溜めるのが早いです。
mkdir -p ~/.claude/hooks
cat > ~/.claude/hooks/dump-input.sh <<'EOF'
#!/bin/bash
cat >> /tmp/claude-hook-dump.jsonl
exit 0
EOF
chmod +x ~/.claude/hooks/dump-input.sh
settings.json に一時的に噛ませて、1セッション動かしてから jq -c 'keys' /tmp/claude-hook-dump.jsonl | sort -u を見る。これでイベントごとのキー構成が確定します。推測で書くより10分早いです。
実装1: session_id ごとにログディレクトリを切るHook
本題です。.company/scripts/ にある共通ヘルパー common.sh と同じ流儀で書いた、セッション分離ロガーがこちら。
~/.claude/hooks/session-log.sh
#!/bin/bash
# Claude Code Hook: セッションIDごとにログを分離する
# stdin から Hook の入力JSONを受け取り、session_id 単位のディレクトリへ追記する。
set -uo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
LOG_ROOT="${CLAUDE_SESSION_LOG_ROOT:-$HOME/.claude/session-logs}"
# stdin は一度しか読めない。必ず変数に受けてから jq に回す。
INPUT="$(cat)"
# jq -r は null をそのまま "null" 文字列にするので // empty で潰す
SESSION_ID="$(printf '%s' "$INPUT" | jq -r '.session_id // empty')"
EVENT="$(printf '%s' "$INPUT" | jq -r '.hook_event_name // "unknown"')"
TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // "-"')"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "-"')"
# session_id が取れない事態(JSON壊れ・イベント仕様変更)でも落とさない
[ -n "$SESSION_ID" ] || SESSION_ID="nosession-$(date +%Y%m%d)"
# パス区切りやドットを含む値を渡されてもディレクトリを脱出させない
SAFE_ID="$(printf '%s' "$SESSION_ID" | tr -cd 'A-Za-z0-9_-' | cut -c1-64)"
[ -n "$SAFE_ID" ] || SAFE_ID="invalid"
SESSION_DIR="$LOG_ROOT/$SAFE_ID"
mkdir -p "$SESSION_DIR"
TS="$(date '+%Y-%m-%d %H:%M:%S')"
# 1. 人が読む行ログ
printf '%s [%s] tool=%s cwd=%s\n' "$TS" "$EVENT" "$TOOL" "$CWD" \
>> "$SESSION_DIR/events.log"
# 2. 機械で集計する生JSON(1行1レコード)
printf '%s\n' "$INPUT" >> "$SESSION_DIR/raw.jsonl"
# 3. セッションの入口を1回だけ記録しておくと --resume で効く
if [ ! -f "$SESSION_DIR/meta.json" ]; then
printf '%s' "$INPUT" \
| jq '{session_id, cwd, transcript_path, started_at: now | todate}' \
> "$SESSION_DIR/meta.json"
fi
exit 0
~/.claude/settings.json(既存の hooks があればマージしてください)
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/session-log.sh" }
]
}
],
"PreToolUse": [
{
"matcher": "Bash|Write|Edit",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/session-log.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/session-log.sh" }
]
}
]
}
}
これで、実行ごとにこうなります。
~/.claude/session-logs/
├── 3f9c1a2e-7b44-4e21-9f0a-5c6d8e2b1a77/
│ ├── events.log # 2026-09-26 22:04:11 [PreToolUse] tool=Bash cwd=/Users/...
│ ├── raw.jsonl
│ └── meta.json
└── a81b0d55-2c19-4f7e-8b33-9ee4c7a01d62/
└── ...
同じスクリプトを二重起動しても、行が混ざりません。原因追跡が「該当ディレクトリを cat する」だけになります。
実装2: 無人実行側で session_id を捕まえる
Hook だけだと片手落ちです。僕の環境では launchd から claude -p を叩くので、シェル側のログとHook側のログを同じIDで揃えたい。
ここで使えるのが --output-format json です。当社の lib/claude-run.sh は、コスト記録のためにすでにこの形式で叩いています(2+2 を聞くだけの1回でも $0.27〜0.47 かかると実測して導入したラッパーです)。
claude_run() {
local label="$1"; shift
claude --output-format json \
--fallback-model claude-sonnet-5 \
--max-budget-usd 3 \
"$@" 2>/dev/null \
| python3 "$_CLAUDE_RUN_LIB/record-cost.py" "$label"
}
この JSON には session_id が入っています。なのでラッパーを少し足して、セッションIDでログを切るようにします。
.company/scripts/lib/claude-run.sh(セッション分離版)
#!/bin/bash
# claude を無人実行し、session_id 単位でログを分離するラッパー
set -uo pipefail
_CLAUDE_RUN_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SESSION_LOG_ROOT="${CLAUDE_SESSION_LOG_ROOT:-$HOME/.claude/session-logs}"
# 使い方: RESULT=$(claude_run dept-dev --effort low -p "…")
claude_run() {
local label="$1"; shift
local raw
raw="$(claude --output-format json \
--fallback-model claude-sonnet-5 \
--max-budget-usd 3 \
"$@" 2>/dev/null)" || {
echo "claude 実行に失敗: $label" >&2
return 1
}
local sid
sid="$(printf '%s' "$raw" | jq -r '.session_id // empty')"
[ -n "$sid" ] || sid="nosession-$(date +%Y%m%d%H%M%S)"
local dir="$SESSION_LOG_ROOT/$sid"
mkdir -p "$dir"
# 実行の素性を残す: ラベル・引数・コスト・所要時間
printf '%s' "$raw" | jq \
--arg label "$label" \
--arg argv "$*" \
'{label: $label, argv: $argv, session_id, total_cost_usd, duration_ms, num_turns, is_error}' \
>> "$dir/runs.jsonl"
# 後続のリトライで使えるよう、直近のIDを固定パスに置く
printf '%s\n' "$sid" > "$SESSION_LOG_ROOT/last-${label}.sid"
printf '%s' "$raw" | python3 "$_CLAUDE_RUN_LIB/record-cost.py" "$label"
}
# 直近セッションを引き継いで再実行する
claude_resume() {
local label="$1"; shift
local sid_file="$SESSION_LOG_ROOT/last-${label}.sid"
[ -f "$sid_file" ] || { echo "セッションIDが未記録: $label" >&2; return 1; }
claude_run "$label" --resume "$(cat "$sid_file")" "$@"
}
呼び出し側は今までどおりです。
source /Users/kyoagun/workspace/one-ceo/.company/scripts/lib/claude-run.sh
RESULT="$(claude_run replenish-articles --effort low -p "Qiitaのストックを3本補充して")"
# 途中で落ちたとき、同じ会話の続きからやり直す
# claude_resume replenish-articles -p "さっきの続きから、残り2本を書いて"
last-<label>.sid を残しておくのがポイントで、失敗したジョブを同じ文脈で再開できるようになります。僕の場合、記事補充が深夜にレートリミットで落ちたとき、翌朝に claude_resume で続きを書かせる運用になりました。ゼロから同じプロンプトを投げ直すより安いです(キャッシュが効く)。
落とし穴(僕が踏んだもの)
1. stdin は一度しか読めない
# ダメ
SESSION_ID="$(jq -r '.session_id' )" # ここで stdin を食い尽くす
EVENT="$(jq -r '.hook_event_name')" # 空になる
INPUT="$(cat)" で先に変数へ受けること。地味ですが、これで30分溶かしました。
2. set -e を Hook に入れると本体が止まる
Hook が非ゼロで終了すると、イベントによっては Claude Code の動作をブロックします。ロギング用途の Hook は set -uo pipefail(e なし)にして、最後に必ず exit 0 を書く。ログ取りが本体を止めるのは本末転倒です。
当社の common.sh は set -euo pipefail + trap ... ERR でSlack通知する設計ですが、Hookだけは別方針にしています。役割が違うからです。
3. PATH が最小になる
launchd も Hook も、ログインシェルの PATH を引き継ぎません。jq が command not found になります。当社のスクリプトが全部 export PATH=... を頭に書いているのはこれが理由で、Hook でも同じ対策が要ります。
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
macOS で cron が全滅した(フルディスクアクセスの問題)ので launchd に全面移行した、という経験があります。実行環境が変わると PATH 前提は必ず崩れます。
4. --resume するとセッションIDは維持される
--resume <id> で再開した場合、以後の Hook にも同じ session_id が渡ってきます。つまりログは同じディレクトリに追記されます。これは仕様として正しいのですが、「1実行=1ディレクトリ」だと思い込んでいるとログ量の見積もりを外します。実行回数を数えたいなら runs.jsonl の行数を見てください。
5. session_id をそのままパスに使わない
外部から来る値をディレクトリ名にするので、tr -cd 'A-Za-z0-9_-' でサニタイズしています。UUID しか来ないはずですが、「はず」で書かないのが運用スクリプトです。
6. ログが無限に増える
セッションごとにディレクトリが増え続けます。launchd に掃除ジョブを1本足しました。
# 30日より古いセッションログを削除
find "$HOME/.claude/session-logs" -mindepth 1 -maxdepth 1 -type d \
-mtime +30 -exec rm -rf {} +
検証方法
仕込んだら必ず確認します。「動いているつもり」が一番高くつきます。
# 1. Hook単体テスト(実セッションを起こさずに検証できる)
echo '{"session_id":"test-1234","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/tmp"}' \
| ~/.claude/hooks/session-log.sh
cat ~/.claude/session-logs/test-1234/events.log
# => 2026-09-26 22:10:03 [PreToolUse] tool=Bash cwd=/tmp
# 2. 異常系: session_id が無くても落ちないか
echo '{"hook_event_name":"Stop"}' | ~/.claude/hooks/session-log.sh; echo "exit=$?"
# => exit=0
# 3. 壊れたJSONでも落ちないか
echo 'not json' | ~/.claude/hooks/session-log.sh; echo "exit=$?"
# => exit=0
# 4. 実セッションで2並列。ディレクトリが2つできることを確認
claude -p "hello" >/dev/null & claude -p "hi" >/dev/null & wait
ls -1t ~/.claude/session-logs | head -3
# 5. 実行あたりのコストと所要時間を集計
cat ~/.claude/session-logs/*/runs.jsonl \
| jq -s 'group_by(.label) | map({label: .[0].label, runs: length,
cost: (map(.total_cost_usd) | add | . * 100 | round / 100)})'
# => [{"label":"replenish-articles","runs":12,"cost":3.41}, ...]
5番が地味に効きます。セッション分離の副産物として、ジョブ別のコストが実測で出るようになりました。当社は Claude Code Max で月2万円ほどですが、どのジョブが重いかは分離するまで分かっていませんでした。
結び: ログ分離は「委任するための仕組み」
自動化を増やすと、必ず「何が起きたか分からない」フェーズが来ます。僕も AI が git push --force を自動実行してブランチを飛ばした事故がありますが、あのとき一番困ったのは事故そのものより、ログから経緯を再構成できなかったことでした。
セッションIDでログを分けるのは、機能追加ではなく観測可能性の投資です。仕組みの初期投資は大きいけれど、一度作れば自動で回る。自動化は「AIの能力を制限する」ためではなく、「安心して委任するための仕組み」だと思っています。
今日やることは3つだけです。
-
dump-input.shで Hook の生JSONを1セッション分ためる -
session-log.shを置いてSessionStart/PreToolUse/Stopに噛ませる - 検証コマンド3本(正常系・session_idなし・壊れたJSON)を流す
30分で終わります。
参考書籍
Hook、launchd、無人実行まわりの設計を一冊にまとめています。この記事で触れた PATH 問題・cron から launchd への移行・承認パイプラインなど、実運用で踏んだ落とし穴を全部収録しました。
『Claude Code 全自動化バイブル』
書籍一覧はこちら: https://zenn.dev/joinclass?tab=books