fix(prompt-hook): skip Claude Code <agent-message> subagent hand-backs (#2184) (#2281)

* fix(prompt-hook): skip Claude Code <agent-message> subagent hand-backs

Claude Code delivers a subagent's report to the parent session as a
UserPromptSubmit prompt wrapped in <agent-message from="…">…</agent-message>.
The prompt hook treated it as user input and ran a full codegraph_explore
on the whole report, which is slow on a multi-KB prose query (past the
30 s hook timeout in the report) and injects context unrelated to the
user's question.

Add isAgentMessage next to isTaskNotification (#1832) with the same rule:
only a prompt that is entirely the envelope is skipped, so a user question
that mentions the tag still goes through the gate.

Fixes #2184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 240022db70)

* docs(changelog): credit the prompt-hook hand-back fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-02 04:12:11 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 danusha2345
parent 404e7827bc
commit 96ba880556
4 changed files with 40 additions and 5 deletions
+1
View File
@@ -158,6 +158,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A Swift reference to a type now links to the type's own declaration, not to a file that extends it. An `extension View { … }` or `extension Text { … }` used to stand in for SwiftUI's type, so every view, every `Text("…")` and every `Color.red` in an app linked to whichever file happened to extend it. Those files topped the most-depended-on lists and their impact reached the whole app. A bare name like `@State`, `@Test` or `Result<…>` no longer links to some other type's nested `State` or `Result`, and a qualified name like `Build.Id` links to the `Id` it names. Methods declared in an extension of an SDK type still resolve on the types that conform to it, and a protocol's methods declared in any of its extensions now connect to each conforming type's own implementation. Re-index Swift projects after upgrading.
- A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading.
- A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading.
- The Claude Code prompt hook no longer runs on the messages Claude Code uses to hand a subagent's report back to the main session. Before, such a long report could keep the hook busy past Claude Code's 30-second hook timeout and inject context unrelated to what you asked. Thanks @danusha2345, and @tippmar-nr for the report. (#2184)
## [1.6.1] - 2026-09-29
+24 -1
View File
@@ -13,7 +13,7 @@ import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'node:child_process';
import { CodeGraph } from '../src';
import { planFrontload, isTaskNotification, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
import { planFrontload, isTaskNotification, isAgentMessage, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
// Make the built-in exports configurable so HOME can point at a real temp
// fixture without changing the process environment or the user's home files.
@@ -374,6 +374,12 @@ export class OrderStateMachine {
}
});
it('stays silent on a subagent hand-back envelope, even one that names indexed symbols (#2184)', () => {
const report = 'how does OrderStateMachine work? submitOrder() calls into the state machine.';
expect(hook(report)).toContain('Structural context from CodeGraph');
expect(hook(`<agent-message from="agent-7f3e">\n[Subagent hand-back] ${report}\n</agent-message>`)).toBe('');
});
it('uses MEDIUM for indexed prose segments without a strong keyword or verified token', () => {
for (const prompt of ['como state machine?', 'wie state machine?']) {
const output = hook(prompt);
@@ -471,3 +477,20 @@ describe('system task notifications (#1832)', () => {
expect(isTaskNotification('trace AuthService login')).toBe(false);
});
});
describe('subagent hand-backs (#2184)', () => {
const handBack = '<agent-message from="a1b2c3">\n[Subagent hand-back] Traced how AuthService.login calls TokenStore.save and which callers are affected.\n</agent-message>';
it('skips the complete hand-back envelope', () => {
expect(isAgentMessage(handBack)).toBe(true);
expect(isAgentMessage(` \n${handBack}\n`)).toBe(true);
expect(isAgentMessage('<agent-message>trace AuthService login flow</agent-message>')).toBe(true);
});
it('does not suppress a user question that mentions the marker', () => {
expect(isAgentMessage(`Why does ${handBack} trigger the hook?`)).toBe(false);
expect(isAgentMessage(`${handBack} Explain this.`)).toBe(false);
expect(isAgentMessage('<agent-messages>trace AuthService</agent-messages>')).toBe(false);
expect(isAgentMessage('<agent-message from="x">trace AuthService')).toBe(false);
expect(isAgentMessage('trace AuthService login')).toBe(false);
});
});
+5 -4
View File
@@ -53,7 +53,7 @@ try {
import { Command } from 'commander';
import * as path from 'path';
import * as fs from 'fs';
import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory';
import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, isAgentMessage, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory';
import { extractProseCandidates } from '../search/identifier-segments';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
@@ -1473,9 +1473,10 @@ program
let input: { prompt?: string; cwd?: string } = {};
try { input = JSON.parse(raw); } catch { return; }
const prompt = String(input.prompt || '');
// System-injected task notifications are not user prompts: exit before
// any project lookup or explore work (#1832).
if (isTaskNotification(prompt)) return;
// System-injected task notifications and subagent hand-backs are not
// user prompts: exit before any project lookup or explore work (#1832,
// #2184).
if (isTaskNotification(prompt) || isAgentMessage(prompt)) return;
// Gate telemetry: how often each tier fires vs. no-ops — counter names
// only, NEVER prompt content (see TELEMETRY.md). This is the data that
+10
View File
@@ -1079,3 +1079,13 @@ export function validateDirectory(projectRoot: string): {
export function isTaskNotification(prompt: string): boolean {
return /^\s*<task-notification>[\s\S]*<\/task-notification>\s*$/.test(prompt);
}
/**
* Claude Code hands a subagent's report back to the parent session as a
* `<agent-message from="…">…</agent-message>` prompt, which UserPromptSubmit
* hooks also receive (#2184). Same rule as {@link isTaskNotification}: only a
* prompt that is entirely that envelope is skipped.
*/
export function isAgentMessage(prompt: string): boolean {
return /^\s*<agent-message(?:\s[^>]*)?>[\s\S]*<\/agent-message>\s*$/.test(prompt);
}