Repository navigation
prompt-hook fires on Claude Code's system-injected <task-notification> messages: 3.5s median, 7.4s max on the blocking path #1832
Description
Summary
UserPromptSubmit fires for messages the user never typed. When a background agent (the Agent
tool) finishes, Claude Code injects a <task-notification> block into the turn as a user message,
and the hook receives it on stdin as input.prompt exactly like a real prompt.
Those blocks are long machine text: task IDs, toolu_* tool-use IDs, scratchpad paths, and the
finishing agent's prose summary about code. They trip hasStructuralKeyword(), which is sufficient
on its own, so the whole blob goes to codegraph_explore as the query:
const result = await handler.execute('codegraph_explore', { query: prompt });Explore's cost tracks the number of distinct identifier-shaped tokens in the query. A notification
block carries a median of 27 of those. My typed prompts carry a median of 0. The result is a
multi-second stall on a hook that blocks the turn.
Measured against my own history, one indexed repo (~57 MB index, large Python codebase), v1.6.0
darwin-arm64, 8 days of ~/.claude/projects/*/*.jsonl:
| what triggered the hook | runs | median | max |
|---|---|---|---|
| no-op (gate declines) | 35 | 220 ms | 761 ms |
<task-notification> (system-injected) |
28 | 3,555 ms | 7,379 ms |
| prompts I actually typed | 14 | 727 ms | 1,148 ms |
36% of the hook's successful firings were on text I never wrote, at roughly 5x the cost of my real
prompts.
Relationship to #1654 and #1694
I checked both before filing. This is a third thing.
#1654 (ambiguous keywords escalate to HIGH) covers human-written prompts whose keywords are
lexically ambiguous. Every direction proposed there would leave this case untouched: routing
keywords through the index check, an "ambiguous interrogative" tier, requiring a second signal. A
task notification is a summary about code. It carries unambiguous structural keywords and
identifier-shaped tokens that verify against the index, because the agent really was working on
those symbols. It passes every proposed gate on the merits. Gate quality is not the problem here;
the input was never a prompt.
#1694 (16k injection cap vs Claude Code's 10k inline limit) covers what happens to the payload
after explore returns. I see that too, on every slow firing (Output too large (15.8KB) plus a 2 KB
preview), but it is already reported and closed. This issue is about the seconds spent before the
payload exists, and about the firing happening at all.
The 10,000-char query cap in ToolHandler (mcp/tools.js) does apply on this path and works
correctly. It sits above where the notifications live: of 82 notification blocks in my transcripts,
77 are under the cap and go straight through.
Repro
Any indexed repo. Both prompts below are ~9 KB and produce the same ~16 KB injection.
#!/usr/bin/env bash
set -euo pipefail
KEYWORDS="It traced the flow through the request handler and refactored the class hierarchy."
# A: 9 KB of repetitive prose, so almost no distinct identifier tokens.
A="<task-notification>$(for i in $(seq 1 110); do printf '%s ' "$KEYWORDS"; done)"
A="${A:0:9000}</task-notification>"
# B: 9 KB of THIS repo's own symbol names, so many distinct identifier tokens.
codegraph explore "request handling" > /tmp/cg-diverse.txt 2>/dev/null || true
B="<task-notification>$(head -c 8900 /tmp/cg-diverse.txt | tr '\n' ' ')</task-notification>"
for pair in "A:$A" "B:$B"; do
label="${pair%%:*}"; prompt="${pair#*:}"
json=$(python3 -c 'import json,sys;print(json.dumps({"session_id":"r","transcript_path":"/tmp/x","cwd":sys.argv[1],"permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":sys.argv[2]}))' "$PWD" "$prompt")
start=$(python3 -c 'import time;print(time.time())')
bytes=$(printf '%s' "$json" | codegraph prompt-hook | wc -c | tr -d ' ')
end=$(python3 -c 'import time;print(time.time())')
printf '%s: chars=%d elapsed=%.2fs injected=%s\n' "$label" "${#prompt}" \
"$(python3 -c "print($end-$start)")" "$bytes"
doneA: chars=9020 elapsed=0.51s injected=16301
B: chars=8899 elapsed=4.46s injected=16303
Same length, same injected payload, 8.7x the wall time. Isolating the variable directly (identical
9,000-char prompts, distinct-token count computed with extractCodeTokens' own rules):
| prompt | chars | distinct identifier tokens | elapsed |
|---|---|---|---|
| repetitive prose | 9,000 | 0 | 0.42s |
| this repo's symbol names | 9,000 | 93 | 4.37s |
Standalone codegraph explore on the same index shows the same curve, around 0.45s per additional
KB of token-dense text, almost entirely user CPU (user 2.25 of real 2.27) rather than I/O.
Daemon auto-sync during these runs was 3-188 ms, so index contention is not a factor, and process
startup is ~50 ms.
Token density: notifications against typed prompts
Same repo, same 8 days, distinct identifier-shaped tokens per message:
| message class | n | median | p90 | max |
|---|---|---|---|---|
<task-notification> blocks |
82 | 27 | 45 | 90 |
| prompts I typed (< 400 chars) | 63 | 0 | 1 | 3 |
The gate and its cost model were built for the second distribution. Notification blocks also cluster
right under the query cap, with a median of 6,011 chars and a p90 of 9,165, which is the most
expensive band that still gets explored.
What I'm not claiming
- Not that explore is slow in general. A normal query against this index returns in ~0.6s, fine for
a blocking hook. - Not that the token-count relationship is precisely linear. Five points on one index. The shape is
clear; the constant is not. - Not that my numbers generalize. One index, one machine, my own agent-heavy workflow. Sessions
without background agents never see this, and my 14 typed prompts stayed near 727 ms. Anyone using
theAgenttool on an indexed repo should see it. Anyone who doesn't, won't. - I have not checked whether other hosts (Codex, ZCode per First-class install target + prompt-hook for ZCode (Z.ai) #1535) push comparable machine text
through the same entry point.
Possible directions, none of which I'm confident in
- Skip host-generated wrapper messages. A prompt opening with a known host wrapper
(<task-notification>,<system-reminder>,<command-name>) is not a user prompt, and the
hook's premise of front-loading context for what the user just asked doesn't apply. Cheapest and
most targeted, but it hardcodes another host's private message format, which will drift. It also
changes behavior with no warning for anyone currently getting useful injections from a
slash-command wrapper. - Cap the query the hook sends, independent of
ToolHandler's 10,000-char guard. Passing the
first N distinct candidate tokens instead of the raw prompt is host-agnostic and bounds the
blocking path for long human prompts too. Needs a recall measurement I haven't done, since
truncation could drop the one token that mattered. - Budget the hook in time rather than size. Race the explore against a ~1s deadline and emit
nothing if it doesn't finish, consistent with the "must NEVER break the user's prompt" contract
in the docstring. Wastes the work anyway and makes injection nondeterministic across machines.
(1) and (2) are complementary. My instinct is (2) as the durable fix with (1) as a fast mitigation,
though I'd want the gate telemetry from #1143 to say how often HIGH fires on wrapper-shaped input
first.
Workaround
CODEGRAPH_NO_PROMPT_HOOK=1, the kill-switch already at the top of the prompt-hook action,
disables the injection while leaving the MCP tool and CLI working. In Claude Code it goes in the
env block of ~/.claude/settings.json.
Environment
- codegraph v1.6.0, darwin-arm64 (macOS 25.6)
- Claude Code 2.1.267, native install
- Index: ~57 MB
codegraph.db, large Python repo, daemon healthy
Analysis and report authored with Claude Code.
Activity
maxmilian commentedon Sep 11, 2026
Independent replication, on a different machine and a much smaller index, plus two clarifications about neighbouring issues. I'm deliberately not arguing for one of your three directions — you said you'd want the #1143 gate telemetry first, and I think that's right. This is just data toward it.
The A/B reproduces. Same script, but against this repo's own index (826 files / 22,707 nodes / 71,813 edges — far smaller than your 57MB Python index), Node 22:
9.5×, with the injected payloads within 1% of each other. Same shape as your 0.51s / 4.46s. The cost tracks distinct identifier tokens, not length — which is why a cap on the injection can't reach it.
How often it fires, measured rather than estimated. I ran the shipped
extractCodeTokens/hasStructuralKeywordout ofdist(not a re-typed regex) over 8 days of my own transcripts:hasStructuralKeyword<task-notification>So: 77 HIGH-tier full explores in 8 days on text I never wrote, against 3% for real prompts. My distribution is much milder than yours (my notifications median 402 chars vs your 6,011), so this isn't a second sample of the same workload — the direction holds independently of how verbose the host's wrappers happen to be.
I also confirmed the premise end-to-end on my side:
~/.claude/projects/*/*.jsonldoes carry these as"type":"user","message":{"role":"user","content":"<task-notification…, so they reach the hook indistinguishable from typed input.Two neighbours, so they don't get conflated:
prompt-hookinjection cap (16k chars) exceeds Claude Code's 10k inline hook-output limit, so the context is persisted to a file and the model sees a 2 KB preview #1694 is already fixed and is a different layer. fix(prompt-hook): cap injection under Claude Code's 10k inline hook-output limit #1769 (4105249) setPROMPT_HOOK_INJECTION_MAX = 9_000; I measured injection at ~9.2KB now, down from the 16,301 in that report. That capped what gets injected. This issue is the seconds spent before injection, so the cap doesn't touch it.That's the part I'd flag for whatever you decide: this isn't a precision problem in the classifier, so it probably can't be fixed inside it.
If the telemetry lands where you expect and you want the fast mitigation, I'm happy to send a PR for it — an anchored-at-start check against known host wrapper tags, early-return plus a gate counter, with tests that a wrapper mentioned mid-prompt by a real user isn't blocked. Just say the word; I won't open one unprompted since the three directions trade off differently and it's your call which one this repo wants.
(Not a maintainer — just triaging.)
colbymchenry commentedon Sep 27, 2026
Thanks for the detailed report, @miketheman, and thanks for using CodeGraph! This is fixed on
mainby #1884 by @danusha2345 (thank you!): the prompt hook now exits immediately on Claude Code's system-injected<task-notification>messages instead of running a code search on them. The fix will be in the next release.fix(prompt-hook): skip Claude Code task notifications (colbymchenry#1832
fix(prompt-hook): skip Claude Code <agent-message> subagent hand-backs (