はじめに
Claude Code の hooks で「作業が完了したら macOS の通知+音声で知らせる」設定をしていました。
ところが、サブエージェントをよく使うようになってから、作業の途中で何度も「作業が完了したよ」と鳴るようになりました。どれが本当の完了なのか分からず、通知の意味がなくなってしまいます。
この記事では、サブエージェントやバックグラウンドのコマンドがすべて終わり、メインのエージェントが止まったときだけ通知するようにした方法を紹介します。
TL;DR
-
Stopフックの完了通知が、サブエージェント使用時に途中で何度も鳴る - 原因はサブエージェントがバックグラウンド実行で、メインが「起動→停止→再開→停止」を繰り返すため
-
transcript_pathのログから「起動したのに未完了のタスク」を数え、0件のときだけ通知するスクリプトで解決 - 権限確認(
PermissionRequest)の通知は、作業が止まっている合図なのであえて抑えない
元の設定
~/.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 だけを使っていればサブエージェントでは鳴らないはずです。
原因は、サブエージェントがバックグラウンドで実行されることでした。流れは次のようになります。
メインはサブエージェントを起動したあと、一度応答を終えて待ちます。ここで 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 のアップデートで変わる可能性があります。
スクリプト
#!/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回にまとめても困りません。一方、権限確認は「自分が答えるまで作業が止まっている」という合図です。サブエージェントが出した権限確認でも、答えるまでそのサブエージェントは止まったままなので、全体の完了も遅れます。抑えてしまうと、誰も気づかないまま全体が止まることになります。
権限確認の通知が多すぎる場合は、通知を減らすのではなく、よく使う読み取り系のコマンドを許可リストに入れて確認そのものを減らすのがよいと思います。
おわりに
Stop フックが「メインエージェントが応答を終えたとき」に発火すること自体は正しい動きですが、バックグラウンド実行と組み合わせると「作業全体の完了」とはずれてしまいます。トランスクリプトから未完了のタスクを数えることで、本当に全部終わったときだけ通知できるようになりました。
同じように通知が鳴りすぎて困っている方の参考になればうれしいです。