はじめに
Claude Code の hooks で「作業が完了したら macOS の通知+音声で知らせる」設定をしていました。
ところが、サブエージェントをよく使うようになってから、作業の途中で何度も「作業が完了したよ」と鳴るようになりました。どれが本当の完了なのか分からず、通知の意味がなくなってしまいます。
この記事では、サブエージェントやバックグラウンドのコマンドがすべて終わり、メインのエージェントが止まったときだけ通知するようにした方法を紹介します。
TL;DR
-
Stopフックの完了通知が、サブエージェント使用時に途中で何度も鳴る - 原因はサブエージェントがバックグラウンド実行で、メインが「起動→停止→再開→停止」を繰り返すため
-
transcript_pathのログから「起動したのに未完了のタスク」を数え、0件のときだけ通知するスクリプトで解決 - 権限確認(
PermissionRequest)の通知は、作業が止まっている合図なのであえて抑えない -
【2026-09-29 追記】
Stopの入力には公式のbackground_tasks配列があり、これが空かどうかで判定するほうが確実。ログの解析は、配列が無いときの予備に回した
元の設定
~/.claude/settings.json の Stop フックで、通知と読み上げをしていました。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"作業が完了したよ\" with title \"Claude Code\"' 2>/dev/null; /usr/bin/say -v Kyoko '作業が完了したよ' 2>/dev/null",
"async": true
}
]
}
]
}
}
なぜ途中で鳴るのか
Stop はメインエージェントが応答を終えたときに発火するイベントです。サブエージェントの終了は別の SubagentStop イベントなので、Stop だけを使っていればサブエージェントでは鳴らないはずです。
原因は、サブエージェントがバックグラウンドで実行されることでした。流れは次のようになります。
2026-09-29 追記:Hooks リファレンスによると、サブエージェントがデフォルトでバックグラウンド実行になるのは Claude Code v2.1.198 以降です。それより古いバージョンや、サブエージェントをフォアグラウンドで動かした場合は、メインが途中で応答を終えないため、この現象は起きません。
メインはサブエージェントを起動したあと、一度応答を終えて待ちます。ここで Stop が発火します。サブエージェントが終わるとメインが再開し、また応答を終えるのでもう一度発火します。レビュー用のサブエージェントを2つ並列で動かすと、合計3回鳴ることになります。
つまり、Stop が発火した時点で、まだ動いているバックグラウンドタスクがあるかを判定できればよいことになります。
判定の手がかり:トランスクリプト
Stop フックには、標準入力の JSON で transcript_path(セッションのログファイル、JSONL 形式)が渡されます。自分の環境で実際のログを調べたところ、次の記録が残っていました。
起動時:ツールの結果(tool_result)に次の文言が入る
- サブエージェント:
Async agent launched successfully. ... - バックグラウンドのコマンド:
Command running in background with ID: ...
完了時:次のような <task-notification> が記録される
<task-notification>
<task-id>...</task-id>
<tool-use-id>toolu_01SBYq4SqbZKJWxQqpr9MVMi</tool-use-id>
<output-file>...</output-file>
<status>completed</status>
...
起動時の tool_result の tool_use_id と、完了通知の <tool-use-id> は同じ値です。そこで、起動した ID の集合から完了した ID の集合を引き、残りが0件なら通知することにしました。
このログの形式は公式ドキュメントに書かれた仕様ではなく、自分の環境で観察した結果です。Claude Code のアップデートで変わる可能性があります。
2026-09-29 追記:公式の入力項目 background_tasks を使えば、ログを解析しなくても同じ判定ができます。詳しくは記事末尾の「追記(2026-09-29)」を参照してください。
スクリプト
#!/usr/bin/env python3
"""Stop フック: バックグラウンドのサブエージェント/コマンドが全て終わっているときだけ完了通知する。"""
import json
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
MESSAGE = "作業が完了したよ"
# 完了通知が記録されずに残った起動記録で、通知が永久に止まるのを防ぐ
STALE_AFTER = timedelta(hours=3)
LAUNCH_MARKERS = ("Async agent launched", "Command running in background")
NOTIF_RE = re.compile(r"<task-notification>.*?<tool-use-id>([^<]+)</tool-use-id>.*?<status>", re.S)
def result_text(block):
c = block.get("content")
if isinstance(c, str):
return c
if isinstance(c, list):
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
return ""
def parse_ts(s):
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def pending_tasks(transcript_path):
launched = {} # tool_use_id -> 起動時刻
finished = set()
with open(transcript_path, encoding="utf-8") as f:
for line in f:
if "Async agent launched" in line or "Command running in background" in line:
try:
d = json.loads(line)
except Exception:
continue
content = (d.get("message") or {}).get("content")
if isinstance(content, list):
for b in content:
if b.get("type") == "tool_result" and any(m in result_text(b) for m in LAUNCH_MARKERS):
launched[b.get("tool_use_id")] = parse_ts(d.get("timestamp", ""))
if "task-notification" in line:
# JSON 内のエスケープを戻してから抽出する
try:
text = json.dumps(json.loads(line), ensure_ascii=False).replace("\\n", "\n")
except Exception:
text = line
finished.update(NOTIF_RE.findall(text))
now = datetime.now(timezone.utc)
return [
tid for tid, ts in launched.items()
if tid not in finished and (ts is None or now - ts < STALE_AFTER)
]
def main():
try:
data = json.load(sys.stdin)
path = data.get("transcript_path")
if path and pending_tasks(path):
return # まだ動いているタスクがある → 通知しない
except Exception:
pass # 判定に失敗したら従来どおり通知する
subprocess.run(
["osascript", "-e", f'display notification "{MESSAGE}" with title "Claude Code"'],
stderr=subprocess.DEVNULL,
)
subprocess.run(["/usr/bin/say", "-v", "Kyoko", MESSAGE], stderr=subprocess.DEVNULL)
if __name__ == "__main__":
main()
工夫した点は次の3つです。
-
バックグラウンドのコマンドも対象にする:
npm run buildなどをバックグラウンドで動かした場合も、完了するとメインが再開します。サブエージェントと同じ扱いにしました。 - 3時間たったものは除外する:何かの理由で完了通知が記録されなかった場合に、そのセッションの通知がずっと鳴らなくなるのを防ぎます。
- 判定に失敗したら通知する:ログの形式が変わって読めなくなったときは、鳴りすぎる側に倒します。通知が来ないまま放置するよりはましだからです。
settings.json の変更
Stop フックのコマンドを、スクリプトを呼ぶ形に差し替えます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/notify_done.py",
"async": true
}
]
}
]
}
}
ユーザー共通の ~/.claude/settings.json に書いたので、どのプロジェクトでも同じように動きます。スクリプトはフックに渡されたトランスクリプトを読むだけで、プロジェクトには依存しません。
動作確認
過去のセッションのログを使って判定部分を確かめました。レビュー用のサブエージェントを2つ並列で起動したセッションです。
- ログ全体(すべて完了済み)→ 残り
[](0件)→ 通知する - 2つを起動した直後の行で切ったログ → 残り2件 → 通知しない
import notify_done as n
print(n.pending_tasks("全体.jsonl")) # []
print(n.pending_tasks("途中.jsonl")) # ['toolu_01SB...', 'toolu_01NE...']
権限確認の通知は同じにしない
PermissionRequest フックでも「権限確認が必要だよ」と通知しています。こちらも同じように抑えるか考えましたが、やめておきました。
完了通知は「終わったよ」という報告なので、最後の1回にまとめても困りません。一方、権限確認は「自分が答えるまで作業が止まっている」という合図です。サブエージェントが出した権限確認でも、答えるまでそのサブエージェントは止まったままなので、全体の完了も遅れます。抑えてしまうと、誰も気づかないまま全体が止まることになります。
2026-09-29 追記:公式ドキュメントに書かれているのは「PermissionRequest は権限ダイアログが表示されるときに実行される」ところまでです。上の「サブエージェントが出した権限確認でも、答えるまでそのサブエージェントは止まったまま」という部分は、ドキュメントで確認したものではなく、自分の考えです。
権限確認の通知が多すぎる場合は、通知を減らすのではなく、よく使う読み取り系のコマンドを許可リストに入れて確認そのものを減らすのがよいと思います。
追記(2026-09-29):公式の background_tasks で判定する
この追記は、Skillselion さんからいただいたコメントをもとに書きました。ご指摘ありがとうございました。
いただいた指摘
-
Stopフックの入力 JSON には、公式のbackground_tasks配列がある - トランスクリプトは書き込みが遅れる場合があるので、サブエージェント起動直後の
Stopでは起動記録がまだ無く、途中で1回鳴る可能性がある - 配列が空かどうかで判定し、配列が無いときだけ今の方法に戻せば、安全側も保てる
公式ドキュメントで確認した内容
Hooks リファレンスで、次のことを確認しました。
-
Stopフックは、共通の入力項目に加えてstop_hook_active・last_assistant_message・background_tasks・session_cronsを受け取る -
background_tasksとsession_cronsは Claude Code v2.1.145 以降で使える。「セッションが完了した」のか「バックグラウンドの作業を待って一時停止している」のかを区別するための項目 - 実行中のものが何もなければ空配列になる。各要素には
id・type(shell・subagentなど)・statusなどが入る - 共通の入力項目
transcript_pathの説明に、トランスクリプトは非同期に書き込まれ、フックの発火時点では今のターンの最新メッセージがまだ含まれていない可能性があると書かれている
本文で「公式の仕様ではない」と書いたログの解析は、公式の入力項目で置き換えられることになります。
スクリプトの変更
変更したのは main() だけです。background_tasks があればそれだけで判定し、無いとき(古いバージョンなど)だけ、これまでのトランスクリプトの解析に戻します。
def main():
try:
data = json.load(sys.stdin)
tasks = data.get("background_tasks")
if isinstance(tasks, list):
# 公式の入力項目で判定する(トランスクリプトの書き込み遅れの影響を受けない)
if tasks:
return # まだ動いているタスクがある → 通知しない
else:
# background_tasks が無い(古いバージョン等)ときはトランスクリプトで判定する
path = data.get("transcript_path")
if path and pending_tasks(path):
return # まだ動いているタスクがある → 通知しない
except Exception:
pass # 判定に失敗したら従来どおり通知する
subprocess.run(
["osascript", "-e", f'display notification "{MESSAGE}" with title "Claude Code"'],
stderr=subprocess.DEVNULL,
)
subprocess.run(["/usr/bin/say", "-v", "Kyoko", MESSAGE], stderr=subprocess.DEVNULL)
session_crons(/loop などで予約した起動)は判定に入れていません。入れると、予約が残っている間はずっと完了通知が鳴らなくなるためです。
判定部分のテスト
通知と読み上げの処理を差し替え、実際には鳴らさない形で判定だけ確かめました。
| 入力 | 結果 |
|---|---|
background_tasks が空配列 |
通知する |
background_tasks に実行中のタスクあり |
通知しない |
| 配列なし・ログに未完了のタスクあり | 通知しない(従来の判定) |
配列なし・transcript_path もなし |
通知する |
実際のセッションでの動きは、引き続き様子を見ています。
本文への補足
コメントをきっかけに本文全体を公式ドキュメントと照らし合わせ直し、次の2点を本文中に追記しました。
- 「なぜ途中で鳴るのか」:サブエージェントがデフォルトでバックグラウンド実行になるのは v2.1.198 以降であること
- 「権限確認の通知は同じにしない」:サブエージェントの権限確認についての記述は、ドキュメントで確認したものではなく自分の考えであること
おわりに
Stop フックが「メインエージェントが応答を終えたとき」に発火すること自体は正しい動きですが、バックグラウンド実行と組み合わせると「作業全体の完了」とはずれてしまいます。トランスクリプトから未完了のタスクを数えることで、本当に全部終わったときだけ通知できるようになりました。
(2026-09-29 追記)その後、公式の background_tasks を使えばログを解析せずに判定できると教えていただき、そちらを優先する形に改めました。
同じように通知が鳴りすぎて困っている方の参考になればうれしいです。