Skip to content

prompt-hook fires on Claude Code's system-injected <task-notification> messages: 3.5s median, 7.4s max on the blocking path #1832

Closed

Description

@miketheman

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"
done
A: 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
    the Agent tool 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

  1. 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.
  2. 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.
  3. 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

  1. maxmilian commented on Sep 11, 2026

    @maxmilian
    Contributor

    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:

    A (9,020 chars of repeated prose, ~0 distinct tokens) : elapsed=0.37s  injected=9,227
    B (8,867 chars of this repo's symbol names)           : elapsed=3.51s  injected=9,301
    

    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 / hasStructuralKeyword out of dist (not a re-typed regex) over 8 days of my own transcripts:

    message class n distinct tokens (median) p90 max hasStructuralKeyword median chars
    <task-notification> 355 3 40 182 77 (22%) 402
    prompts I actually typed 686 0 1 7 22 (3%) 17

    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/*/*.jsonl does 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:

    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.)

  2. colbymchenry commented on Sep 27, 2026

    @colbymchenry
    Owner

    Thanks for the detailed report, @miketheman, and thanks for using CodeGraph! This is fixed on main by #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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions