From c1744e7e7630f2781486a722012cd5de08c9da76 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 22 Oct 2026 11:44:29 +0000 Subject: [PATCH] fix(prompt-hook): desktop reminders and other sessions' messages no longer drive the hook (#2798) Claude Code's desktop app puts blocks (which local sessions are linked, and similar status) in front of the prompt it submits, and UserPromptSubmit hooks receive them inside `prompt`. The hook gated, picked the project and explored on the whole string, so the reminder's words drove it: a linked session titled after its work, like "trace-order-flow", carries structural keywords, so every turn, a bare "yes" included, got a full explore of the reminder. A reminder in front of a task notification or a subagent hand-back also defeated the start-anchored envelope checks, and a message from another session (a envelope) was never skipped at all. The hook now removes every complete block before the envelope checks, the gate, project selection and explore, returns early when nothing is left, and skips a prompt that is entirely a envelope, as it already did for task notifications and subagent hand-backs. The scan stops at the last closing tag and the attribute run stops at `<`, so a pasted prompt full of unclosed tags stays linear (520 KB in under 1 ms; the `[^>]*` form took 83 ms on a tenth of that and grows quadratically). Validation: - Prompt shapes come from the Claude Code desktop transcripts on this machine (314 transcripts): reminders always lead, one to four of them, separated by one or two newlines, and cross-session messages follow one to four reminders. - frontload-hook.test.ts: 15 new tests (8 spawn the built hook with Claude Code-shaped payloads) fail on main and pass with the fix; the file's 48 other tests pass on both. - Built CLI, private HOME, 13 payloads: on main every prompt behind a reminder got a full explore of the reminder (2.1 to 5.0 KB); with the fix the 8 that are not user input inject nothing and exit in about 200 ms instead of 680 ms, and a question behind reminders injects byte-for-byte what the same question alone does. Issue #2635. Contributor PR #2642 (yang0228) was read for guidance. Fixes #2635 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/frontload-hook.test.ts | 173 +++++++++++++++++++++++++++---- src/bin/codegraph.ts | 15 +-- src/directory.ts | 38 +++++++ 4 files changed, 201 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d35ba91..d8d9c4e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582) - In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599) - `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011. +- The Claude Code prompt hook now goes by what you typed. Claude Code's desktop app puts status notes, like which other sessions are linked, in front of your prompt, and the hook read them as part of it, so every turn, even a one-word "yes", could get code context about the note's words, and so could a task notification, a subagent's report or a message from another session that came after one. The notes are now skipped, and a message from another session gets no context, as task notifications and subagent reports already didn't. Thanks @WonderMr for the report and @yang0228 for the first fix. (#2635) - `codegraph_explore` and `codegraph context` now find your project's busiest file, and favor the code around it, even when twenty or more test or generated files are busier still, such as a package of protobuf stubs. Before, the search looked only at the 20 busiest files and then set the tests and generated ones aside, so it could find no file at all, or judge one far ahead of the next when it was not. `getTopRouteFile()` in the library counts every file's routes the same way. Thanks @inth3shadows for the report. (#2578) - In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585). - `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565) diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts index c85700b9c..f4c1a9bea 100644 --- a/__tests__/frontload-hook.test.ts +++ b/__tests__/frontload-hook.test.ts @@ -7,13 +7,13 @@ * `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision * logic), plus the built CLI's confidence tiers against a real index. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { spawnSync } from 'node:child_process'; import { CodeGraph } from '../src'; -import { planFrontload, isTaskNotification, isAgentMessage, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; +import { planFrontload, isTaskNotification, isAgentMessage, isCrossSessionMessage, stripSystemReminders, 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. @@ -35,6 +35,37 @@ function mkWorkspaceRoot(dir: string): string { return dir; } +/** + * Run the built CLI's `prompt-hook` in `dir` with a UserPromptSubmit payload + * shaped like the one Claude Code sends, and return what it injects. + */ +function runPromptHook(dir: string, prompt: string): string { + const payload = { + session_id: '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', + transcript_path: path.join(dir, 'transcript.jsonl'), + cwd: dir, + permission_mode: 'default', + hook_event_name: 'UserPromptSubmit', + prompt, + }; + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../dist/bin/codegraph.js'), 'prompt-hook'], { + cwd: dir, + input: JSON.stringify(payload), + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_DAEMON: '1', + CODEGRAPH_NO_RELAUNCH: '1', CODEGRAPH_WASM_RELAUNCHED: '1', + // Enable only the hook subprocess under test. + CODEGRAPH_NO_PROMPT_HOOK: '0', CODEGRAPH_PROMPT_HOOK: '1', + }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + return result.stdout; +} + describe('planFrontload — front-load hook project resolution (#964)', () => { let tmp: string; beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); }); @@ -321,24 +352,7 @@ export class OrderStateMachine { }); afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); - function hook(prompt: string): string { - const result = spawnSync(process.execPath, [path.resolve(__dirname, '../dist/bin/codegraph.js'), 'prompt-hook'], { - cwd: tmp, - input: JSON.stringify({ cwd: tmp, prompt }), - encoding: 'utf8', - timeout: 15_000, - env: { - ...process.env, - CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_DAEMON: '1', - CODEGRAPH_NO_RELAUNCH: '1', CODEGRAPH_WASM_RELAUNCHED: '1', - // Enable only the hook subprocess under test. - CODEGRAPH_NO_PROMPT_HOOK: '0', CODEGRAPH_PROMPT_HOOK: '1', - }, - }); - expect(result.error).toBeUndefined(); - expect(result.status, result.stderr).toBe(0); - return result.stdout; - } + const hook = (prompt: string): string => runPromptHook(tmp, prompt); it('stays silent for everyday Portuguese, Spanish and German without indexed evidence', () => { for (const prompt of [ @@ -390,6 +404,70 @@ export class OrderStateMachine { }); }); +describe('prompt-hook reads past the desktop app\'s system reminders (#2635)', () => { + // Shaped like the prompts Claude Code's desktop app submits: one or more + // reminder blocks, separated by one or two newlines, then what the user + // typed or another session's message. A linked session's title names the + // work, so the reminder carries structural words ("trace", "flow") of its own. + const linked = '\nLinked sessions now (status, not an instruction): trace-order-flow = local_5f0c2a1e-9b7d-4c3a-8e21-3d4f5a6b7c8d (working). To message one, use SendMessage with its id.\n'; + const workspace = '\nThis session runs in a desktop workspace; write temporary files to its scratch folder.\n'; + const reminders = `${linked}\n${workspace}\n\n`; + let tmp: string; + + beforeAll(async () => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hook-reminders-'))); + fs.writeFileSync(path.join(tmp, 'orders.ts'), ` +export class OrderStateMachine { + submitOrder() { return this.transition('submitted'); } + transition(state: string) { return state.length > 0; } +} +`); + const cg = await CodeGraph.init(tmp, { silent: true }); + try { await cg.indexAll(); } finally { cg.destroy(); } + }); + afterAll(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + const hook = (prompt: string): string => runPromptHook(tmp, prompt); + + it('stays silent on a short reply that follows reminders', () => { + expect(hook('yes')).toBe(''); + expect(hook(`${reminders}yes`)).toBe(''); + }); + + it('stays silent when the prompt is only reminders', () => { + expect(hook(reminders)).toBe(''); + expect(hook(` \n${linked}\n\n${workspace}\n`)).toBe(''); + }); + + it('explores what the user typed, not the reminders', () => { + const output = hook(`${reminders}how does submitOrder reach transition?`); + expect(output).toContain('Structural context from CodeGraph'); + expect(output).toContain('**Exploration: how does submitOrder reach transition?**'); + expect(output).not.toContain('Linked sessions'); + expect(output).not.toContain('trace-order-flow'); + }); + + it('gates on what the user typed: prose that names indexed symbols gets the MEDIUM tier', () => { + const output = hook(`${linked}\n\nstate machine`); + expect(output).toContain('CodeGraph found indexed symbols matching this prompt'); + expect(output).toContain('OrderStateMachine'); + expect(output).not.toContain('Structural context from CodeGraph'); + }); + + it.each([ + ['a message from another session', '\nI traced how OrderStateMachine.submitOrder() reaches transition(). Ready for review.\n'], + ['a task notification', '\nb7d3k2\ncompleted\nBackground command "trace submitOrder flow" completed (exit code 0)\n'], + ['a subagent hand-back', '\n[Subagent hand-back] submitOrder() calls transition() on OrderStateMachine.\n'], + ])('stays silent on %s behind reminders', (_label, envelope) => { + expect(hook(`${linked}\n\n${envelope}`)).toBe(''); + expect(hook(`${reminders}${envelope}`)).toBe(''); + }); + + it('stays silent on a message from another session on its own', () => { + expect(hook('\nhow does OrderStateMachine work?\n')).toBe(''); + }); +}); + describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => { it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => { expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish'); @@ -494,3 +572,58 @@ describe('subagent hand-backs (#2184)', () => { expect(isAgentMessage('trace AuthService login')).toBe(false); }); }); + +describe('messages from other sessions (#2635)', () => { + const message = '\nTraced how AuthService.login calls TokenStore.save.\n'; + + it('skips the complete envelope', () => { + expect(isCrossSessionMessage(message)).toBe(true); + expect(isCrossSessionMessage(` \n${message}\n`)).toBe(true); + expect(isCrossSessionMessage('trace AuthService login flow')).toBe(true); + }); + it('does not suppress a user question that mentions the marker', () => { + expect(isCrossSessionMessage(`Why does ${message} trigger the hook?`)).toBe(false); + expect(isCrossSessionMessage(`${message} Explain this.`)).toBe(false); + expect(isCrossSessionMessage('trace AuthService')).toBe(false); + expect(isCrossSessionMessage('trace AuthService')).toBe(false); + expect(isCrossSessionMessage('trace AuthService login')).toBe(false); + }); +}); + +describe('stripSystemReminders (#2635)', () => { + const reminder = '\nLinked sessions now (status, not an instruction): trace-auth = local_5f0c2a1e (working).\n'; + + it('drops the reminders in front of the prompt, however they are separated', () => { + expect(stripSystemReminders(`${reminder}\n\nhow does AuthService.login work?`)).toBe('how does AuthService.login work?'); + expect(stripSystemReminders(`${reminder}\n${reminder}\n\nyes`)).toBe('yes'); + expect(stripSystemReminders(`${reminder}yes`)).toBe('yes'); + expect(stripSystemReminders('status\nyes')).toBe('yes'); + }); + + it('leaves nothing when the prompt is only reminders', () => { + expect(stripSystemReminders(reminder)).toBe(''); + expect(stripSystemReminders(` \n${reminder}\n\n${reminder}\n`)).toBe(''); + }); + + it('keeps the user\'s text around a reminder elsewhere in the prompt, and keeps envelopes for the checks after it', () => { + expect(stripSystemReminders(`why does the hook react to this?\n\n${reminder}`)).toBe('why does the hook react to this?'); + expect(stripSystemReminders(`log line one\n${reminder}\nlog line two`)).toBe('log line one\n\n\nlog line two'); + const envelope = 'trace AuthService'; + expect(stripSystemReminders(`${reminder}\n\n${envelope}`)).toBe(envelope); + expect(isCrossSessionMessage(stripSystemReminders(`${reminder}\n\n${envelope}`))).toBe(true); + }); + + it('leaves a prompt that only mentions the tag alone', () => { + expect(stripSystemReminders('how does AuthService.login work?')).toBe('how does AuthService.login work?'); + expect(stripSystemReminders('why does show up in the hook input?')).toBe('why does show up in the hook input?'); + expect(stripSystemReminders('trace AuthService')).toBe('trace AuthService'); + expect(stripSystemReminders('a stray closing tag')).toBe('a stray closing tag'); + }); + + it('stays linear on a long pasted prompt full of unclosed tags', () => { + const pasted = `${'`; + const started = Date.now(); + stripSystemReminders(pasted); + expect(Date.now() - started).toBeLessThan(1_000); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 296d950d1..119bd3c87 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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, isAgentMessage, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; +import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, isAgentMessage, isCrossSessionMessage, stripSystemReminders, 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'; @@ -1512,11 +1512,14 @@ program let input: { prompt?: string; cwd?: string } = {}; try { input = JSON.parse(raw); } catch { return; } - const prompt = String(input.prompt || ''); - // 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; + // The desktop app's blocks are host status, not what + // the user typed: gate, pick the project and explore on the rest + // (#2635). + const prompt = stripSystemReminders(String(input.prompt || '')); + // System-injected task notifications, subagent hand-backs and messages + // from other sessions are not user prompts, and neither is nothing: + // exit before any project lookup or explore work (#1832, #2184, #2635). + if (!prompt || isTaskNotification(prompt) || isAgentMessage(prompt) || isCrossSessionMessage(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 diff --git a/src/directory.ts b/src/directory.ts index eb359a0c6..2e7445d38 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -1125,3 +1125,41 @@ export function isTaskNotification(prompt: string): boolean { export function isAgentMessage(prompt: string): boolean { return /^\s*]*)?>[\s\S]*<\/agent-message>\s*$/.test(prompt); } + +/** + * A message another local Claude Code session sends to this one arrives as a + * `…` prompt, which + * UserPromptSubmit hooks also receive (#2635). Same rule as + * {@link isTaskNotification}: only a prompt that is entirely that envelope is + * skipped. + */ +export function isCrossSessionMessage(prompt: string): boolean { + return /^\s*]*)?>[\s\S]*<\/cross-session-message>\s*$/.test(prompt); +} + +const SYSTEM_REMINDER_CLOSE = ''; +/** One complete reminder block. The attribute run stops at `<`, so a tag left + * open in pasted text can't make the scan quadratic. */ +const SYSTEM_REMINDER_BLOCK = /]*)?>[\s\S]*?<\/system-reminder>/g; + +/** + * The prompt the user typed, without the `…` + * blocks Claude Code's desktop app puts in front of it, such as which other + * local sessions are linked. UserPromptSubmit hooks receive them as + * part of the prompt (#2635), but they are the host's status, not the user's + * words: gated and explored on, they made every turn, a bare "yes" included, + * inject context about the reminder, and they hid a task notification, a + * subagent hand-back or a cross-session message behind them from + * {@link isTaskNotification}, {@link isAgentMessage} and + * {@link isCrossSessionMessage}. Every complete block is removed and the rest + * trimmed; a `` the user only mentions, with no closing tag, + * stays. + */ +export function stripSystemReminders(prompt: string): string { + // Only the text up to the last closing tag can hold a complete block, and + // bounding the scan there keeps an unclosed tag from rescanning the tail. + const end = prompt.lastIndexOf(SYSTEM_REMINDER_CLOSE); + if (end < 0) return prompt.trim(); + const head = prompt.slice(0, end + SYSTEM_REMINDER_CLOSE.length); + return (head.replace(SYSTEM_REMINDER_BLOCK, '\n') + prompt.slice(head.length)).trim(); +}