fix(prompt-hook): desktop reminders and other sessions' messages no longer drive the hook (#2798)

Claude Code's desktop app puts <system-reminder> 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 <cross-session-message from=...> envelope) was never skipped at all.

The hook now removes every complete <system-reminder> 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
<cross-session-message> 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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-22 11:44:29 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 407efb1c65
commit c1744e7e76
4 changed files with 201 additions and 26 deletions
+9 -6
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, 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 <system-reminder> 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
+38
View File
@@ -1125,3 +1125,41 @@ export function isTaskNotification(prompt: string): boolean {
export function isAgentMessage(prompt: string): boolean {
return /^\s*<agent-message(?:\s[^>]*)?>[\s\S]*<\/agent-message>\s*$/.test(prompt);
}
/**
* A message another local Claude Code session sends to this one arrives as a
* `<cross-session-message from="…">…</cross-session-message>` 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*<cross-session-message(?:\s[^>]*)?>[\s\S]*<\/cross-session-message>\s*$/.test(prompt);
}
const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
/** 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 = /<system-reminder(?:\s[^<>]*)?>[\s\S]*?<\/system-reminder>/g;
/**
* The prompt the user typed, without the `<system-reminder>…</system-reminder>`
* 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 `<system-reminder>` 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();
}